Files
aiturk-hermes-ide/apps/desktop/src/plugins/hermes-bots/user-sections.ts
T

248 lines
8.0 KiB
TypeScript

/**
* USER SECTIONS — folders the user makes, not folders the topology makes.
*
* The roster already had sections (`roster-sections.tsx`), but only AUTOMATIC
* ones: one per gateway connection, plus the group-chat bucket. Those answer
* "where does this bot run", which is not the question you are asking when you
* want two client bots filed together under "Clients".
*
* So this is a SECOND axis, and it composes with the first rather than
* replacing it. Two deliberate choices:
*
* * The membership lives on the BOT (`sectionId` in its ui_meta), not as a
* member list on the section. A bot can only be in one place, deleting a
* section cannot orphan anybody, and the assignment rides the same
* profile.yaml sync every other bot setting already uses — so sections
* follow the profile to another machine.
* * "Unassigned" is not a section. It is whatever is left, always drawn
* last, and it is where members of a deleted section land. With no
* sections at all the roster renders exactly as it did before.
*
* Pure model + session atoms. No JSX — the pane composes it.
*/
import { atom } from 'nanostores'
import { $botMeta, saveBotMeta } from './data'
import { botRosterMeta } from './routing'
import { getPluginCtx } from './shared'
import type { BotMeta, RosterRow } from './types'
export const UNASSIGNED_SECTION_KEY = 'section:unassigned'
export const BOT_SECTIONS_KEY = 'bot-sections-v1'
export interface BotSection {
id: string
name: string
}
/** `[{ id, name }]`, in display order. */
export const $botSections = atom<BotSection[]>([])
/** Roster key of the bot in flight during a drag. Session-only, and cleared
* on dragend even when the drop lands outside any target — a stuck
* "dragging" state outlives the gesture and reads as a broken pane. */
export const $draggingBot = atom<null | string>(null)
export function normalizeBotSections(value: unknown): BotSection[] {
if (!Array.isArray(value)) {
return []
}
const seen = new Set<string>()
const out: BotSection[] = []
for (const entry of value) {
const id = String((entry as BotSection)?.id || '').trim()
const name = String((entry as BotSection)?.name || '').trim()
if (!id || !name || seen.has(id)) {
continue
}
seen.add(id)
out.push({ id, name })
}
return out
}
function persistBotSections(next: BotSection[]): void {
$botSections.set(next)
try {
getPluginCtx()?.storage?.set?.(BOT_SECTIONS_KEY, next)
} catch {
// No storage — sections live for this window only, which is strictly
// better than the pane throwing while the user drags a bot into a folder.
}
}
/** Read the persisted list back at plugin start. */
export function loadBotSections(): void {
try {
$botSections.set(normalizeBotSections(getPluginCtx()?.storage?.get?.(BOT_SECTIONS_KEY, [])))
} catch {
$botSections.set([])
}
}
function newSectionId(): string {
return `sec-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`
}
/** Create a section and file `bots` into it. Returns the new section, or
* null when the name is blank. */
export function createBotSection(name: string, bots: RosterRow[] = []): BotSection | null {
const clean = String(name || '').trim()
if (!clean) {
return null
}
const section: BotSection = { id: newSectionId(), name: clean }
persistBotSections([...$botSections.get(), section])
void moveBotsToSection(bots, section.id)
return section
}
export function renameBotSection(id: string, name: string): void {
const clean = String(name || '').trim()
if (!clean) {
return
}
persistBotSections($botSections.get().map(s => (s.id === id ? { ...s, name: clean } : s)))
}
/**
* Delete the section only. Its members are not deleted and not hidden — they
* fall back to Unassigned, which is the whole reason membership lives on the
* bot rather than on the section. Returns an undo that puts the section back
* in its slot and refiles the same bots, so the delete needs no confirmation.
*/
export function deleteBotSection(id: string, roster: RosterRow[] = []): { members: RosterRow[]; undo: () => void } {
const list = $botSections.get()
const index = list.findIndex(s => s.id === id)
const section = list[index]
const members = (roster || []).filter(bot => botSectionId(bot, $botMeta.get()) === id)
persistBotSections(list.filter(s => s.id !== id))
void moveBotsToSection(members, null)
return {
members,
undo: () => {
if (!section) {
return
}
const current = $botSections.get().filter(s => s.id !== id)
current.splice(Math.min(index, current.length), 0, section)
persistBotSections(current)
void moveBotsToSection(members, id)
}
}
}
export function moveBotSection(id: string, delta: number): void {
const list = $botSections.get()
const from = list.findIndex(s => s.id === id)
const to = from + delta
if (from < 0 || to < 0 || to >= list.length) {
return
}
const next = list.slice()
const [moved] = next.splice(from, 1)
next.splice(to, 0, moved!)
persistBotSections(next)
}
/**
* `null` clears the assignment (back to Unassigned). One `saveBotMeta` per
* bot — membership is a field on each bot's own profile, so that IS one write
* per profile — and the writes run in sequence rather than fanned out, so the
* shared local snapshot is never committed by two saves at once.
*/
export async function moveBotsToSection(bots: RosterRow[], sectionId: null | string): Promise<void> {
for (const bot of bots || []) {
if (bot && botSectionId(bot, $botMeta.get()) !== (sectionId || null)) {
await saveBotMeta(bot, { sectionId: sectionId || null })
}
}
}
export function botSectionId(bot: RosterRow, metaByName: Record<string, BotMeta>): null | string {
const id = botRosterMeta(bot, metaByName)?.sectionId
return id ? String(id) : null
}
export interface SectionBlock<TRow> {
id: null | string
key: string
name: string
rows: TRow[]
}
/**
* Split roster rows into section blocks, in section order, with Unassigned
* last. Pure, and returns EVERY row exactly once: a row whose `sectionId`
* names a section that no longer exists lands in Unassigned rather than
* vanishing, which is what makes deleting a section safe.
*/
export function groupRowsBySection<TRow extends { bot?: RosterRow } | RosterRow>(
rows: TRow[],
sections: unknown,
metaByName: Record<string, BotMeta>
): SectionBlock<TRow>[] {
const list = normalizeBotSections(sections)
const known = new Set(list.map(s => s.id))
const byId = new Map<string, TRow[]>(list.map(s => [s.id, [] as TRow[]]))
const loose: TRow[] = []
for (const row of rows || []) {
const bot = ((row as { bot?: RosterRow })?.bot || row) as RosterRow
const id = bot ? botSectionId(bot, metaByName) : null
if (id && known.has(id)) {
byId.get(id)!.push(row)
} else {
loose.push(row)
}
}
const blocks: SectionBlock<TRow>[] = list.map(section => ({
id: section.id,
key: `section:${section.id}`,
name: section.name,
rows: byId.get(section.id) || []
}))
blocks.push({ id: null, key: UNASSIGNED_SECTION_KEY, name: '', rows: loose })
return blocks
}
// ── drag and drop ────────────────────────────────────────────────────────────
//
// Filing a bot by dragging it onto a section, which is the gesture people
// reach for first; the row's "Move to section" submenu is the same action
// for anyone who does not.
//
// A CUSTOM MIME TYPE, not `text/plain`: the roster shares a window with the
// composer, the transcript and the tab strip, all of which accept dropped
// text. A private type means a bot dragged onto any of them is simply not a
// valid payload there, instead of pasting its roster key into someone's
// message. `dataTransfer.types` is readable during dragover (the DATA itself
// is not, by design), so a drop target can still light up correctly.
export const BOT_DRAG_MIME = 'application/x-hermes-bot-key'