/** * 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([]) /** 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) export function normalizeBotSections(value: unknown): BotSection[] { if (!Array.isArray(value)) { return [] } const seen = new Set() 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 { 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): null | string { const id = botRosterMeta(bot, metaByName)?.sectionId return id ? String(id) : null } export interface SectionBlock { 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( rows: TRow[], sections: unknown, metaByName: Record ): SectionBlock[] { const list = normalizeBotSections(sections) const known = new Set(list.map(s => s.id)) const byId = new Map(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[] = 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'