diff --git a/apps/desktop/e2e/bot-roster-user-sections.spec.ts b/apps/desktop/e2e/bot-roster-user-sections.spec.ts new file mode 100644 index 0000000000..1b2e6753ab --- /dev/null +++ b/apps/desktop/e2e/bot-roster-user-sections.spec.ts @@ -0,0 +1,254 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { + buildAppEnv, + createSandbox, + launchDesktop, + type MockBackendFixture, + waitForAppReady, + writeEnvFile, + writeMockProviderConfig +} from './fixtures' +import { startMockServer } from './mock-server' +import { RealSessionBuilder } from './real-session-builder' +import { expect, test } from './test' + +// User-made sections in the Bots roster: a bot is filed by dragging it onto a +// section or through its row menu, the section is renamed through the same +// dialog shape sessions use, and deleting a section returns its bots to +// Unassigned (with an Undo toast, no confirmation). With no sections created +// the roster is the plain list it always was. + +type Page = MockBackendFixture['page'] + +let fixture: MockBackendFixture | null = null + +// BOT_SECTIONS_SCREENSHOT_DIR= saves full-window captures at the key +// states — handy for design review; never part of the assertions. +async function capture(page: Page, name: string): Promise { + const dir = process.env.BOT_SECTIONS_SCREENSHOT_DIR + + if (!dir) { + return + } + + fs.mkdirSync(dir, { recursive: true }) + await page.screenshot({ path: path.join(dir, `${name}.png`) }) +} + +async function seedBot(hermesHome: string, mockUrl: string, name: string): Promise { + const dir = path.join(hermesHome, 'profiles', name) + fs.mkdirSync(dir, { recursive: true }) + writeMockProviderConfig(dir, mockUrl) + writeEnvFile(dir) + + const builder = await RealSessionBuilder.start(dir) + + try { + await builder.createSession({ title: 'Bot Chat', turns: [`Hello ${name}`] }) + } finally { + await builder.close() + } +} + +const roster = (page: Page) => page.locator('[data-slot="bots-roster"]') +const botRow = (page: Page, name: string) => roster(page).locator(`[data-roster-key="local::${name}"]`) + +/** A section's label span — the one node whose text is exactly the name. */ +const sectionLabel = (page: Page, name: string) => + page.locator('span.truncate', { hasText: new RegExp(`^${name}$`, 'i') }) + +/** The heading's fold button (label + count) — the ⋯ menu trigger is a sibling with no text. */ +const sectionHeading = (page: Page, name: string) => + roster(page).locator('[data-slot="bots-section"] button[aria-expanded]').filter({ has: sectionLabel(page, name) }) + +const sectionBlock = (page: Page, name: string) => + roster(page).locator('[data-slot="bots-section"]').filter({ has: sectionLabel(page, name) }) + +/** Section name → roster keys of the rows under it (the plain list has no sections). */ +async function layout(page: Page): Promise> { + return roster(page).locator('[data-slot="bots-section"]').evaluateAll(blocks => + blocks.map(block => [ + block.querySelector('button[aria-expanded] span.truncate')?.textContent?.trim() ?? '', + [...block.querySelectorAll('[data-roster-key]')].map(row => row.dataset.rosterKey ?? '') + ]) + ) +} + +test.beforeAll(async () => { + const mock = await startMockServer() + const sandbox = createSandbox('bots-sections') + writeMockProviderConfig(sandbox.hermesHome, mock.url) + writeEnvFile(sandbox.hermesHome) + + for (const name of ['alpha', 'beta', 'gamma']) { + await seedBot(sandbox.hermesHome, mock.url, name) + } + + const { app, page } = await launchDesktop(buildAppEnv(sandbox)) + + fixture = { + app, + page, + mock, + mockUrl: mock.url, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + await mock.close() + sandbox.cleanup() + } + } + await waitForAppReady(fixture, 120_000) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test('file bots into user sections by menu and drag; rename; delete returns them to Unassigned', async () => { + test.setTimeout(300_000) + const page = fixture!.page + + const tab = page + .getByRole('button', { name: 'Bots', exact: true }) + .or(page.getByRole('tab', { name: 'Bots', exact: true })) + .first() + + await tab.click() + await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible() + await expect(botRow(page, 'alpha')).toBeVisible({ timeout: 30_000 }) + await expect(botRow(page, 'beta')).toBeVisible({ timeout: 30_000 }) + + // No sections yet: the plain list, no section chrome at all. + await expect(roster(page).locator('[data-slot="bots-section"]')).toHaveCount(0) + await capture(page, '1-plain-roster') + + // Right-click alpha → Move to section → New section… → name it → alpha is filed. + await botRow(page, 'alpha').click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Move to section' }).hover() + await expect(page.getByRole('menuitem', { name: 'New section…' })).toBeVisible() + await capture(page, '2-row-menu-move-to-section') + await page.getByRole('menuitem', { name: 'New section…' }).click() + const nameField = page.getByRole('textbox', { name: 'Section name' }) + await expect(nameField).toBeVisible() + await nameField.fill('Clients') + await capture(page, '3-new-section-dialog') + await page.getByRole('button', { name: 'Create' }).click() + + await expect(sectionHeading(page, 'Clients')).toBeVisible() + await expect(sectionBlock(page, 'Clients').locator('[data-roster-key="local::alpha"]')).toBeVisible() + // The remainder is Unassigned, drawn last. + await expect + .poll(async () => (await layout(page)).map(([name, keys]) => [name, keys.length])) + .toEqual([ + ['Clients', 1], + ['Unassigned', 3] + ]) + await capture(page, '4-alpha-filed') + + // Drag beta over the Clients block: the target highlights while over it. + // Escape cancels — nothing moves, nothing stays highlighted or faded. + const target = sectionBlock(page, 'Clients') + const from = (await botRow(page, 'beta').boundingBox())! + const to = (await sectionHeading(page, 'Clients').boundingBox())! + + const dragBetaOverClients = async () => { + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2) + await page.mouse.down() + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2 - 10, { steps: 4 }) + await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 12 }) + await expect(target).toHaveAttribute('data-drop-over', 'true') + } + + await dragBetaOverClients() + await page.keyboard.press('Escape') + await page.mouse.up() + await expect(target).not.toHaveAttribute('data-drop-over', 'true') + await expect(botRow(page, 'beta')).toHaveCSS('opacity', '1') + expect((await layout(page)).map(([name, keys]) => [name, keys.length])).toEqual([ + ['Clients', 1], + ['Unassigned', 3] + ]) + + // Drop it for real: the bot is filed. + await dragBetaOverClients() + await capture(page, '5-drag-over-clients') + await page.mouse.up() + + await expect(target.locator('[data-roster-key="local::beta"]')).toBeVisible() + await expect(target).not.toHaveAttribute('data-drop-over', 'true') + // The moved row remounts under its new section; it must not stay faded. + await expect(botRow(page, 'beta')).toHaveCSS('opacity', '1') + await expect + .poll(async () => (await layout(page)).map(([name, keys]) => [name, keys.length])) + .toEqual([ + ['Clients', 2], + ['Unassigned', 2] + ]) + await capture(page, '6-beta-dropped') + + // Rename through the heading's context menu — the same Dialog + Input + // + Save shape as a session rename. + await sectionHeading(page, 'Clients').click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Rename…' }).click() + await expect(nameField).toHaveValue('Clients') + await nameField.fill('Customers') + await page.getByRole('button', { name: 'Save' }).click() + await expect(sectionHeading(page, 'Customers')).toBeVisible() + await expect(sectionHeading(page, 'Clients')).toHaveCount(0) + await capture(page, '7-renamed') + + // A second, empty section from the + menu shows its drop hint; collapsing + // a section folds its rows like the gateway headings do. + await page.getByRole('button', { name: 'New bot or group chat' }).click() + await page.getByRole('menuitem', { name: 'New section' }).click() + await nameField.fill('Team') + await page.getByRole('button', { name: 'Create' }).click() + await expect(sectionBlock(page, 'Team').getByText('Drag bots here')).toBeVisible() + await sectionHeading(page, 'Customers').click() + await expect(sectionBlock(page, 'Customers').locator('[data-roster-key]')).toHaveCount(0) + await capture(page, '8-empty-section-and-collapsed') + await sectionHeading(page, 'Customers').click() + await expect(sectionBlock(page, 'Customers').locator('[data-roster-key]')).toHaveCount(2) + + // Delete Customers: no confirmation, its two bots return to Unassigned, + // and the toast offers Undo. + await sectionHeading(page, 'Customers').click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Delete' }).click() + await expect(sectionHeading(page, 'Customers')).toHaveCount(0) + const toast = page.getByRole('status').filter({ hasText: 'Deleted “Customers”' }) + await expect(toast).toBeVisible() + await expect + .poll(async () => (await layout(page)).map(([name, keys]) => [name, keys.length])) + .toEqual([ + ['Team', 0], + ['Unassigned', 4] + ]) + await capture(page, '9-deleted-with-undo-toast') + + await toast.getByRole('button', { name: 'Undo' }).click() + await expect(sectionHeading(page, 'Customers')).toBeVisible() + await expect + .poll(async () => (await layout(page)).map(([name, keys]) => [name, keys.length])) + .toEqual([ + ['Customers', 2], + ['Team', 0], + ['Unassigned', 2] + ]) + + // Membership rides the bot's profile ui_meta, so it follows profile sync. + const alphaProfile = path.join(fixture!.sandbox.hermesHome, 'profiles', 'alpha', 'profile.yaml') + await expect.poll(() => (fs.existsSync(alphaProfile) ? fs.readFileSync(alphaProfile, 'utf8') : '')).toMatch(/sectionId:\s*sec-/) + + // Delete both sections: the roster is the plain list again. + for (const name of ['Customers', 'Team']) { + await sectionHeading(page, name).click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Delete' }).click() + } + + await expect(roster(page).locator('[data-slot="bots-section"]')).toHaveCount(0) + await expect(botRow(page, 'alpha')).toBeVisible() +}) diff --git a/apps/desktop/src/plugins/hermes-bots/bot-row.test.tsx b/apps/desktop/src/plugins/hermes-bots/bot-row.test.tsx index a477589144..55dcc8e2df 100644 --- a/apps/desktop/src/plugins/hermes-bots/bot-row.test.tsx +++ b/apps/desktop/src/plugins/hermes-bots/bot-row.test.tsx @@ -59,7 +59,7 @@ vi.mock('./roster-actions', () => ({ openRosterBot })) const noop = () => undefined function renderRow(bot: RosterRow) { - render() + render() return screen.getByRole('button') } diff --git a/apps/desktop/src/plugins/hermes-bots/bot-row.tsx b/apps/desktop/src/plugins/hermes-bots/bot-row.tsx index 4fb8ab23f2..a89ef20169 100644 --- a/apps/desktop/src/plugins/hermes-bots/bot-row.tsx +++ b/apps/desktop/src/plugins/hermes-bots/bot-row.tsx @@ -65,18 +65,7 @@ import { openRosterBot } from './roster-actions' import { botRosterMeta, botWorkspaceOwnerKey, setBotsWorkspaceOwner } from './routing' import { A2A_PREFIX_RE, botCanonicalSessionId, botRowOwnsWorkspace, previewKind, workerActiveAt } from './row-helpers' import type { GroupMember, RosterRow, SidebarRowLabels } from './types' -import { - $botPickAnchor, - $botPicked, - $botSections, - $draggingBots, - $renamingSection, - BOT_DRAG_MIME, - botDragPayload, - botSectionId, - createBotSection, - moveBotsToSection -} from './user-sections' +import { $botSections, $draggingBot, BOT_DRAG_MIME, botSectionId, moveBotsToSection } from './user-sections' // ── bot row ────────────────────────────────────────────────────────────────── @@ -96,10 +85,12 @@ interface BotRowProps { onDelete: (bot: RosterRow) => void onEdit: (bot: RosterRow) => void onGroup: (bot: RosterRow) => void + /** Opens the New section dialog; the bot is filed into it on create. */ + onNewSection: (bot: RosterRow) => void showHandle?: boolean } -export function BotRow({ bot, onDelete, onEdit, onGroup, showHandle }: BotRowProps) { +export function BotRow({ bot, onDelete, onEdit, onGroup, onNewSection, showHandle }: BotRowProps) { const { t } = useI18n() const b = useBots() const activeProfile = useValue(host.state.profile) @@ -222,69 +213,15 @@ export function BotRow({ bot, onDelete, onEdit, onGroup, showHandle }: BotRowPro // activate a source and resolve the canonical Bot Chat. const open = () => void openRosterBot(bot) - // MULTI-SELECT AND DRAG live on the row button itself: it already takes - // pointer events, so the click that opens the bot, the cmd/shift click that - // picks it, and the drag that files it are one element's gestures. A PLAIN - // click clears the selection, which is what every list on the platform does. + // DRAG lives on the row button itself: it already takes pointer events, so + // the click that opens the bot and the drag that files it are one element's + // gestures. The drag carries the roster key under a private MIME type, so + // only a section block can accept it. const rosterKey = botRosterKey(bot) - const picked = useValue($botPicked) - const isPicked = picked.includes(rosterKey) const sections = useValue($botSections) + const dragging = useValue($draggingBot) === rosterKey const currentSectionId = botSectionId(bot, allMeta) - const onRowClick = (event: React.MouseEvent) => { - // SHIFT-CLICK RANGE SELECT, anchored at the last plain or cmd-click, the - // way Finder and Mail anchor a range: a second shift-click grows or shrinks - // the SAME range instead of re-anchoring wherever the pointer happens to be. - if (event.shiftKey) { - event.preventDefault() - event.stopPropagation() - - // DOCUMENT ORDER, not roster order. The roster renders grouped into - // section blocks (and gateway sections above those), so two rows adjacent - // in the flat list can be pages apart on screen. The rendered rows are - // the only source that cannot disagree with what the user saw. - const keys = Array.from( - event.currentTarget.closest('[data-slot="bots-roster"]')?.querySelectorAll('[data-roster-key]') ?? [] - ).map(node => String((node as HTMLElement).dataset.rosterKey || '')) - - const anchorKey = $botPickAnchor.get() - const anchorIdx = anchorKey ? keys.indexOf(anchorKey) : -1 - const targetIdx = keys.indexOf(rosterKey) - - if (anchorIdx === -1 || targetIdx === -1) { - $botPicked.set([rosterKey]) - $botPickAnchor.set(rosterKey) - - return - } - - const [lo, hi] = anchorIdx < targetIdx ? [anchorIdx, targetIdx] : [targetIdx, anchorIdx] - - $botPicked.set(keys.slice(lo, hi + 1)) - - return - } - - if (event.metaKey || event.ctrlKey) { - event.preventDefault() - event.stopPropagation() - $botPicked.set(isPicked ? picked.filter(key => key !== rosterKey) : [...picked, rosterKey]) - $botPickAnchor.set(rosterKey) - - return - } - - $botPicked.set([]) - $botPickAnchor.set(rosterKey) - open() - } - - // What a section action applies to: the multi-selection when this row is in - // it, otherwise just this row. Never a selection this row is not part of. - const targets = (): RosterRow[] => - isPicked ? $lastRoster.get().filter(row => picked.includes(botRosterKey(row))) : [bot] - const row = ( $draggingBots.set([])} + onClick={open} + onDragEnd={() => $draggingBot.set(null)} onDragStart={event => { - // Drag the whole multi-selection when this row is part of it — the - // same rule `targets()` uses for the section submenu, so the two - // gestures can never disagree about what "this" means. - const keys = isPicked && picked.length ? picked : [rosterKey] - - event.dataTransfer.setData(BOT_DRAG_MIME, botDragPayload(keys)) + event.dataTransfer.setData(BOT_DRAG_MIME, rosterKey) event.dataTransfer.effectAllowed = 'move' - $draggingBots.set(keys) + $draggingBot.set(rosterKey) }} onPointerEnter={warm} > @@ -478,41 +412,27 @@ export function BotRow({ bot, onDelete, onEdit, onGroup, showHandle }: BotRowPro this is a one-field write and no list anywhere has to be kept in sync with it. */} - - {isPicked && picked.length > 1 ? `Move ${picked.length} bots to…` : 'Move to section…'} - + {b.sections.moveTo} {sections.map(section => ( { - moveBotsToSection(targets(), section.id) - $botPicked.set([]) - }} + onSelect={() => void moveBotsToSection([bot], section.id)} > + {section.name} ))} {sections.length ? : null} - { - const section = createBotSection('New section', targets()) - - $botPicked.set([]) - $renamingSection.set(section.id) - }} - > - New section… + onNewSection(bot)}> + + {b.sections.newSectionEllipsis} {currentSectionId ? ( - { - moveBotsToSection(targets(), null) - $botPicked.set([]) - }} - > - Unassigned + void moveBotsToSection([bot], null)}> + + {b.sections.removeFromSection} ) : null} diff --git a/apps/desktop/src/plugins/hermes-bots/i18n.ts b/apps/desktop/src/plugins/hermes-bots/i18n.ts index 3c8e74a7f7..477dbef093 100644 --- a/apps/desktop/src/plugins/hermes-bots/i18n.ts +++ b/apps/desktop/src/plugins/hermes-bots/i18n.ts @@ -75,6 +75,27 @@ type BotsMessages = { rosterUnavailable: (reason: string) => string waitingForGateway: string } + /** User-made roster sections (folders the user files bots into). */ + sections: { + newSection: string + newTitle: string + renameTitle: string + nameLabel: string + namePlaceholder: string + create: string + rename: string + moveUp: string + moveDown: string + unassigned: string + options: (name: string) => string + headingTip: string + emptyHint: string + moveTo: string + newSectionEllipsis: string + removeFromSection: string + deleted: (name: string, count: number) => string + undo: string + } /** Creating, editing and removing a bot. */ bot: { newTitle: string @@ -284,6 +305,29 @@ const en: BotsMessages = { waitingForGateway: 'Waiting for the gateway connection… (remote gateways can take a few seconds; retries automatically)' }, + sections: { + newSection: 'New section', + newTitle: 'New section', + renameTitle: 'Rename section', + nameLabel: 'Section name', + namePlaceholder: 'e.g. Clients', + create: 'Create', + rename: 'Rename…', + moveUp: 'Move up', + moveDown: 'Move down', + unassigned: 'Unassigned', + options: name => `${name} section options`, + headingTip: 'Drop bots here · double-click to rename', + emptyHint: 'Drag bots here', + moveTo: 'Move to section', + newSectionEllipsis: 'New section…', + removeFromSection: 'Remove from section', + deleted: (name, count) => + count === 0 + ? `Deleted “${name}”` + : `Deleted “${name}” — ${count} ${count === 1 ? 'bot' : 'bots'} moved to Unassigned`, + undo: 'Undo' + }, bot: { newTitle: 'New bot', editTitle: 'Edit profile', @@ -478,6 +522,27 @@ const ja: BotsMessages = { `名簿を取得できません: ${reason}。ゲートウェイが profiles.list より前の場合は、Hermes を更新してゲートウェイを再起動してください。`, waitingForGateway: 'ゲートウェイ接続を待っています…(リモートは数秒かかることがあります。自動で再試行します)' }, + sections: { + newSection: '新しいセクション', + newTitle: '新しいセクション', + renameTitle: 'セクション名を変更', + nameLabel: 'セクション名', + namePlaceholder: '例: クライアント', + create: '作成', + rename: '名前を変更…', + moveUp: '上へ移動', + moveDown: '下へ移動', + unassigned: '未分類', + options: name => `${name} セクションのオプション`, + headingTip: 'ここにボットをドロップ · ダブルクリックで名前を変更', + emptyHint: 'ここにボットをドラッグ', + moveTo: 'セクションへ移動', + newSectionEllipsis: '新しいセクション…', + removeFromSection: 'セクションから外す', + deleted: (name, count) => + count === 0 ? `「${name}」を削除しました` : `「${name}」を削除しました — ${count} 件のボットを未分類に移動しました`, + undo: '元に戻す' + }, bot: { newTitle: '新しいボット', editTitle: 'プロファイルを編集', @@ -671,6 +736,27 @@ const zh: BotsMessages = { rosterUnavailable: reason => `无法获取名单:${reason}。如果网关早于 profiles.list,请更新 Hermes 并重启网关。`, waitingForGateway: '正在等待网关连接…(远程网关可能需要几秒;会自动重试)' }, + sections: { + newSection: '新建分区', + newTitle: '新建分区', + renameTitle: '重命名分区', + nameLabel: '分区名称', + namePlaceholder: '例如:客户', + create: '创建', + rename: '重命名…', + moveUp: '上移', + moveDown: '下移', + unassigned: '未分类', + options: name => `${name} 分区选项`, + headingTip: '将机器人拖放到此处 · 双击重命名', + emptyHint: '将机器人拖到此处', + moveTo: '移动到分区', + newSectionEllipsis: '新建分区…', + removeFromSection: '移出分区', + deleted: (name, count) => + count === 0 ? `已删除“${name}”` : `已删除“${name}” — ${count} 个机器人已移至未分类`, + undo: '撤销' + }, bot: { newTitle: '新建机器人', editTitle: '编辑配置档案', @@ -864,6 +950,27 @@ const zhHant: BotsMessages = { rosterUnavailable: reason => `無法取得名單:${reason}。如果閘道早於 profiles.list,請更新 Hermes 並重新啟動閘道。`, waitingForGateway: '正在等待閘道連線…(遠端閘道可能需要幾秒;會自動重試)' }, + sections: { + newSection: '新增分區', + newTitle: '新增分區', + renameTitle: '重新命名分區', + nameLabel: '分區名稱', + namePlaceholder: '例如:客戶', + create: '建立', + rename: '重新命名…', + moveUp: '上移', + moveDown: '下移', + unassigned: '未分類', + options: name => `${name} 分區選項`, + headingTip: '將機器人拖放到此處 · 雙擊重新命名', + emptyHint: '將機器人拖到此處', + moveTo: '移動到分區', + newSectionEllipsis: '新增分區…', + removeFromSection: '移出分區', + deleted: (name, count) => + count === 0 ? `已刪除「${name}」` : `已刪除「${name}」— ${count} 個機器人已移至未分類`, + undo: '復原' + }, bot: { newTitle: '新增機器人', editTitle: '編輯設定檔', diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.tsx b/apps/desktop/src/plugins/hermes-bots/plugin.tsx index fff080a0a3..02f52826a8 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.tsx +++ b/apps/desktop/src/plugins/hermes-bots/plugin.tsx @@ -95,10 +95,10 @@ export default { description: 'Bot Mode — a one-chat-per-agent roster with avatars, routines, group chats, and bot-to-bot messaging. Ships with the app; disable here if unwanted.', register(ctx: PluginContext) { - // The user's own roster folders. Read once at register; every mutation - // writes through `persistBotSections`. - void loadBotSections() setPluginCtx(ctx) + // The user's own roster sections. Read once at register; every mutation + // writes through. + loadBotSections() const disposeLocales = ctx.i18n.register(BOTS_LOCALES) setGroupChatSyncDisposed(false) startFaceClock() diff --git a/apps/desktop/src/plugins/hermes-bots/roster-pane.tsx b/apps/desktop/src/plugins/hermes-bots/roster-pane.tsx index 58950dc516..b87c1062ef 100644 --- a/apps/desktop/src/plugins/hermes-bots/roster-pane.tsx +++ b/apps/desktop/src/plugins/hermes-bots/roster-pane.tsx @@ -82,17 +82,16 @@ import { backfillMessagingProtocol } from './soul' import type { BotMeta, GatewaySource, GroupMember, RosterActivityFilter, RosterKindFilter, RosterRow } from './types' import { $botSections, - $renamingSection, - BOT_DRAG_MIME, + $draggingBot, createBotSection, deleteBotSection, groupRowsBySection, moveBotSection, moveBotsToSection, - readBotDragPayload, + renameBotSection, UNASSIGNED_SECTION_KEY } from './user-sections' -import { UserSectionHeader } from './user-sections-ui' +import { SectionDropZone, SectionNameDialog, useEscapeCancelsBotDrag, UserSectionHeader } from './user-sections-ui' /** Last source inventory returned by the desktop-wide agent roster. */ const $lastSources = atom([]) @@ -265,10 +264,16 @@ export function BotsPane() { // it is not part of the shared RosterRow model, so it rides as an extra here. const [deleting, setDeleting] = useState(null) const [deletingGroup, setDeletingGroup] = useState(null) - // Which section block the pointer is over mid-drag, by section key. One - // value, not a per-block flag: only one block can be hovered at a time. - const [dropTarget, setDropTarget] = useState(null) const userSections = useValue($botSections) + const dragging = useValue($draggingBot) + useEscapeCancelsBotDrag() + + // The one name dialog serves both New section (optionally filing the bot + // whose menu opened it) and Rename. + const [sectionDialog, setSectionDialog] = useState< + null | { bot?: RosterRow; mode: 'create' } | { id: string; mode: 'rename'; name: string } + >(null) + const [grouping, setGrouping] = useState(null) const [query, setQuery] = useState('') const [rowKindFilter, setRowKindFilter] = useState('all') @@ -556,6 +561,7 @@ export function BotsPane() { onDelete={setDeleting} onEdit={setEditing} onGroup={setGrouping} + onNewSection={target => setSectionDialog({ bot: target, mode: 'create' })} showHandle={botNeedsHandleLabel(bot, roster, allMeta)} /> ) @@ -572,87 +578,93 @@ export function BotsPane() { /> ) + const removeSection = (id: string) => { + const name = userSections.find(section => section.id === id)?.name || '' + const { members, undo } = deleteBotSection(id, roster) + + // No confirmation: nothing is lost (the bots fall back to Unassigned) and + // the toast's Undo puts the section and its members back. + host.notify({ + action: { label: b.sections.undo, onClick: undo }, + durationMs: 8_000, + kind: 'info', + message: b.sections.deleted(name, members.length) + }) + } + // USER SECTIONS — composed with the gateway sections, not instead of them. - // When the roster is showing more than one connection the gateway headings - // still own the top level (that axis answers "where does this run", which no - // folder name can); user sections group the flat list. - const renderUserSections = () => { - // Nothing filed yet, and no folders made: draw the plain list rather than - // one "Unassigned" heading over the whole roster, which tells you nothing. + // The gateway headings own the top level whenever the roster shows more + // than one connection (that axis answers "where does this run", which no + // folder name can, and a bot's membership lives in its profile on THAT + // gateway); user sections group the rows INSIDE each connection bucket, + // indented under it, and group the flat list when there is only one. + // `keyPrefix` keeps row keys unique across the gateway buckets. + type UserSectionRow = { bot: RosterRow; kind?: 'bot' } | RosterGroupRow + + const renderUserSections = (rows: UserSectionRow[], keyPrefix = '') => { + // No sections made: the plain list, exactly as before this feature. if (!userSections.length) { - return rosterRows.map(row => (row.kind === 'group' ? renderGroupRow(row) : renderBotRow(row.bot))) + return rows.map(row => (row.kind === 'group' ? renderGroupRow(row) : renderBotRow(row.bot, keyPrefix))) } + const nested = Boolean(keyPrefix) + const blocks = groupRowsBySection(rows, userSections, allMeta) + return ( - groupRowsBySection(rosterRows, userSections, allMeta) + blocks // An empty Unassigned is not worth a heading; an empty NAMED section // is, because it is somewhere the user made and is about to drop into. - .filter(block => block.id || block.rows.length) + // Inside a gateway bucket the same empty section would repeat under + // every connection, so there it only appears while a drag is in flight + // (as the drop target it exists for); the row menu files into it + // regardless. + .filter(block => block.rows.length || (block.id && (!nested || dragging))) .map(block => { - const key = block.id ? `user-section:${block.id}` : UNASSIGNED_SECTION_KEY + const key = `${keyPrefix}${block.id ? `user-section:${block.id}` : UNASSIGNED_SECTION_KEY}` const collapsed = rosterSectionCollapsed(key) + const order = userSections.findIndex(section => section.id === block.id) return ( -
row.kind !== 'group' && botRosterKey(row.bot) === dragging)} key={key} - onDragLeave={event => { - // Only clear when the pointer leaves the BLOCK, not when it - // crosses between the rows inside it — dragleave fires on every - // child boundary, which otherwise strobes the highlight. - if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { - setDropTarget(null) - } - }} - onDragOver={event => { - if (!event.dataTransfer.types.includes(BOT_DRAG_MIME)) { - return - } + nested={nested} + onDropBot={rosterKey => { + const bot = roster.find(row => botRosterKey(row) === rosterKey) - // preventDefault is what MAKES this a drop target — without it - // the browser refuses the drop and the cursor stays "no entry". - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - setDropTarget(key) - }} - onDrop={event => { - const keys = readBotDragPayload(event.dataTransfer.getData(BOT_DRAG_MIME)) - - setDropTarget(null) - - if (!keys.length) { - return - } - - event.preventDefault() // `block.id` is null for Unassigned, which is exactly the value // moveBotsToSection wants for "clear the assignment". - moveBotsToSection( - roster.filter(row => keys.includes(botRosterKey(row))), - block.id - ) + if (bot) { + void moveBotsToSection([bot], block.id) + } }} > = 0 && order < userSections.length - 1} + canMoveUp={order > 0} collapsed={collapsed} count={block.rows.length} id={block.id} name={block.name} - onDelete={() => block.id && deleteBotSection(block.id, roster)} + onDelete={() => block.id && removeSection(block.id)} onMove={delta => block.id && moveBotSection(block.id, delta)} + onRename={() => block.id && setSectionDialog({ id: block.id, mode: 'rename', name: block.name })} onToggle={() => toggleRosterSection(key)} /> - {collapsed ? null : ( + {collapsed ? null : block.rows.length ? (
{block.rows.map(row => row.kind === 'group' ? renderGroupRow(row) : renderBotRow(row.bot, `${key}:`) )}
+ ) : ( + // Empty section: a quiet dashed slot that says what it is for, + // and doubles as a roomy drop target. +
+ {b.sections.emptyHint} +
)} -
+ ) }) ) @@ -671,7 +683,9 @@ export function BotsPane() { option={section.option} /> {collapsed ? null : ( -
{section.rows.map(row => renderBotRow(row.bot, `${section.id}:`))}
+
+ {renderUserSections(section.rows, `${section.id}:`)} +
)} ) @@ -750,17 +764,10 @@ export function BotsPane() { {b.group.newTitle} - { - const section = createBotSection('New section') - - // Straight into the rename: a folder you cannot name at the - // moment you make it is a folder called "New section". - $renamingSection.set(section.id) - }} - > - - New section + + setSectionDialog({ mode: 'create' })}> + + {b.sections.newSection} @@ -936,7 +943,7 @@ export function BotsPane() { sortedGroupRows.length ? renderGroupChatSection() : null, ...gatewaySections.sections.map(renderGatewaySection) ].filter(Boolean) - : renderUserSections()} + : renderUserSections(rosterRows)} {showHiddenSection ? (
+ { + if (!open) { + setSectionDialog(null) + } + }} + onSubmit={name => { + if (sectionDialog?.mode === 'rename') { + renameBotSection(sectionDialog.id, name) + } else { + createBotSection(name, sectionDialog?.bot ? [sectionDialog.bot] : []) + } + }} + open={Boolean(sectionDialog)} + /> { diff --git a/apps/desktop/src/plugins/hermes-bots/roster-sections.tsx b/apps/desktop/src/plugins/hermes-bots/roster-sections.tsx index dd4cd01378..fb1d1e3a6d 100644 --- a/apps/desktop/src/plugins/hermes-bots/roster-sections.tsx +++ b/apps/desktop/src/plugins/hermes-bots/roster-sections.tsx @@ -7,7 +7,8 @@ * without either half knowing about a bot row. */ -import { Codicon, ConnectionGlyph, DisclosureCaret, RowButton, Tip } from '@hermes/plugin-sdk' +import { cn, Codicon, ConnectionGlyph, DisclosureCaret, RowButton, Tip } from '@hermes/plugin-sdk' +import type { ReactNode } from 'react' import { botHandle, botRosterKey, botSourceStatus, filterBots } from './data' import { displayName } from './labels' @@ -222,22 +223,28 @@ export function GatewayKindGlyph({ className, kind }: GatewayKindGlyphProps) { /** Foldable roster heading. It organizes rows visually but never supplies or * reconstructs ownership; every action still receives the full bot row. */ interface RosterSectionHeaderProps { + /** Trailing control drawn beside the heading (outside its button — a + * button cannot nest a button). User sections put their ⋯ menu here. */ + action?: ReactNode collapsed: boolean count: number gatewayKind?: string icon?: string label: string + onDoubleClick?: () => void onToggle: () => void status?: { available: boolean; label: string } tip?: string } export function RosterSectionHeader({ + action, collapsed, count, gatewayKind, icon, label, + onDoubleClick, onToggle, status, tip @@ -245,8 +252,12 @@ export function RosterSectionHeader({ const button = ( {gatewayKind ? ( @@ -269,7 +280,18 @@ export function RosterSectionHeader({ ) - return tip ? {button} : button + const heading = tip ? {button} : button + + // With a trailing action, heading and action share one hover group so the + // action can reveal on hover of the whole row. + return action ? ( +
+ {heading} + {action} +
+ ) : ( + heading + ) } interface GatewaySectionHeadingProps { diff --git a/apps/desktop/src/plugins/hermes-bots/user-sections-ui.tsx b/apps/desktop/src/plugins/hermes-bots/user-sections-ui.tsx index c8d7bc5ca8..609f1a7a97 100644 --- a/apps/desktop/src/plugins/hermes-bots/user-sections-ui.tsx +++ b/apps/desktop/src/plugins/hermes-bots/user-sections-ui.tsx @@ -1,29 +1,121 @@ /** - * The chrome for user sections: one foldable heading with an inline rename and - * a small menu. The model is in `user-sections.ts`; nothing here holds state - * that outlives a caret. + * The chrome for user sections: the foldable heading (the roster's own + * `RosterSectionHeader`, with a ⋯ menu and a right-click menu that drive the + * same actions), the name dialog used for both New section and Rename (the + * same shape the app's session rename uses), and the drop zone a section + * block sits in. The model is in `user-sections.ts`; nothing here holds state + * that outlives a dialog. */ import { + Button, cn, Codicon, ContextMenu, ContextMenuContent, ContextMenuItem, + ContextMenuSeparator, ContextMenuTrigger, - DisclosureCaret, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, - RowButton, + Input, + useI18n, useValue } from '@hermes/plugin-sdk' -import { useRef, useState } from 'react' +import { type DragEvent, type ReactNode, useEffect, useRef, useState } from 'react' -import { $botSections, $renamingSection, renameBotSection, setBotSectionIcon } from './user-sections' +import { useBots } from './i18n' +import { RosterSectionHeader } from './roster-sections' +import { $draggingBot, BOT_DRAG_MIME } from './user-sections' + +// ── name dialog ────────────────────────────────────────────────────────────── + +interface SectionNameDialogProps { + /** Blank for New section, the current name for Rename. */ + initialName: string + mode: 'create' | 'rename' + onOpenChange: (open: boolean) => void + onSubmit: (name: string) => void + open: boolean +} + +/** One small dialog for both creating and renaming a section — the app renames + * sessions through the same Dialog + Input + Cancel/Save shape, so a section + * rename feels like every other rename. */ +export function SectionNameDialog({ initialName, mode, onOpenChange, onSubmit, open }: SectionNameDialogProps) { + const { t } = useI18n() + const b = useBots() + const [value, setValue] = useState(initialName) + const inputRef = useRef(null) + + useEffect(() => { + if (open) { + setValue(initialName) + window.setTimeout(() => inputRef.current?.select(), 0) + } + }, [initialName, open]) + + const submit = () => { + const next = value.trim() + + if (!next) { + return + } + + onOpenChange(false) + + if (mode === 'create' || next !== initialName.trim()) { + onSubmit(next) + } + } + + return ( + + + + {mode === 'create' ? b.sections.newTitle : b.sections.renameTitle} + + setValue(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter' && !event.nativeEvent.isComposing) { + event.preventDefault() + submit() + } + }} + placeholder={b.sections.namePlaceholder} + ref={inputRef} + value={value} + /> + + + + + + + ) +} + +// ── heading ────────────────────────────────────────────────────────────────── interface UserSectionHeaderProps { + canMoveDown: boolean + canMoveUp: boolean collapsed: boolean count: number /** null for Unassigned, which has no record and therefore no menu. */ @@ -31,154 +123,222 @@ interface UserSectionHeaderProps { name: string onDelete: () => void onMove: (delta: number) => void + onRename: () => void onToggle: () => void } export function UserSectionHeader({ + canMoveDown, + canMoveUp, collapsed, count, id, name, onDelete, onMove, + onRename, onToggle }: UserSectionHeaderProps) { - const renamingId = useValue($renamingSection) - // Absent means show it, so only an explicit false hides the glyph. - const showIcon = useValue($botSections).find(section => section.id === id)?.icon !== false - const renaming = Boolean(id) && renamingId === id - const [draft, setDraft] = useState(name) - // Escape must CANCEL. Closing the field unmounts the input, and an unmount - // can still fire its onBlur — which used to commit the draft the user had - // just asked to throw away. Enter goes through blur too, so the commit runs - // once whichever way the field closes. - const cancelled = useRef(false) + const b = useBots() + const { t } = useI18n() - const commit = () => { - const wasCancelled = cancelled.current - - cancelled.current = false - $renamingSection.set(null) - - if (!wasCancelled && id && draft.trim() && draft.trim() !== name) { - renameBotSection(id, draft) - } + // Unassigned has no record to rename, reorder or delete — it is whatever is + // left over — so it gets the plain heading rather than a menu of disabled + // items. + if (!id) { + return ( + + ) } // RIGHT-CLICK IS THE SAME MENU. The ⋯ button only appears on hover and is a // small target; right-clicking the heading is what people actually try // first. Both drive the identical actions, so neither can drift. - const sectionMenu = id ? ( - - { - setDraft(name) - $renamingSection.set(id) - }} - > - Rename - - setBotSectionIcon(id, !showIcon)}> - {showIcon ? 'Hide icon' : 'Show icon'} - - onMove(-1)}>Move up - onMove(1)}>Move down - - Delete section (keeps its bots) - - - ) : null + const items = [ + { icon: 'edit', label: b.sections.rename, onSelect: onRename }, + { disabled: !canMoveUp, icon: 'arrow-up', label: b.sections.moveUp, onSelect: () => onMove(-1) }, + { disabled: !canMoveDown, icon: 'arrow-down', label: b.sections.moveDown, onSelect: () => onMove(1) } + ] - const header = ( -
- {renaming ? ( - setDraft(event.target.value)} - onKeyDown={event => { - if (event.key === 'Enter') { - // Blur commits; calling commit() here as well ran it twice. - event.currentTarget.blur() - } - - if (event.key === 'Escape') { - cancelled.current = true - setDraft(name) - event.currentTarget.blur() - } - }} - value={draft} - /> - ) : ( - { - if (id) { - setDraft(name) - $renamingSection.set(id) - } - }} + const action = ( + + + - - - { - setDraft(name) - $renamingSection.set(id) - }} - > - Rename - - setBotSectionIcon(id, !showIcon)}> - {showIcon ? 'Hide icon' : 'Show icon'} - - onMove(-1)}>Move up - onMove(1)}>Move down - {/* Deleting a section keeps every bot in it — they fall back to - Unassigned. Said plainly here so nobody has to find out. */} - - Delete section (keeps its bots) - - - - ) : null} -
+ + + + + {items.map(item => ( + + + {item.label} + + ))} + + + + {t.common.delete} + + + ) - return sectionMenu ? ( + return ( - {header} - {sectionMenu} + +
+ +
+
+ + {items.map(item => ( + + {item.label} + + ))} + + + {t.common.delete} + +
- ) : ( - header + ) +} + +// ── drop zone ──────────────────────────────────────────────────────────────── + +/** While a bot is in flight, Escape cancels the gesture. Mount once in the + * roster pane. */ +export function useEscapeCancelsBotDrag(): void { + const dragging = useValue($draggingBot) + + useEffect(() => { + if (!dragging) { + return + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + $draggingBot.set(null) + } + } + + window.addEventListener('keydown', onKeyDown, true) + + return () => window.removeEventListener('keydown', onKeyDown, true) + }, [dragging]) +} + +interface SectionDropZoneProps { + children: ReactNode + /** Whether the dragged bot is already filed here — then the zone is not a + * target, and the OS shows the no-drop cursor instead of a highlight that + * promises a move that would change nothing. */ + isSource: boolean + /** Drawn inside a gateway bucket: indented under a hairline rail so the + * two heading levels read as parent and child. */ + nested?: boolean + onDropBot: (rosterKey: string) => void +} + +/** A section block as a drop target: the whole block (heading + rows, or the + * empty placeholder) lights up while a bot is over it. */ +export function SectionDropZone({ children, isSource, nested, onDropBot }: SectionDropZoneProps) { + const dragging = useValue($draggingBot) + const [over, setOver] = useState(false) + const armed = Boolean(dragging) && !isSource + const lit = armed && over + + // Escape cancels the gesture: the in-flight key is cleared (see the + // keydown hook in the roster pane), so every zone disarms at once and a + // drop that still lands is refused below. Reset the hover so the next drag + // starts clean. + useEffect(() => { + if (!dragging) { + setOver(false) + } + }, [dragging]) + + const accepts = (event: DragEvent) => armed && event.dataTransfer.types.includes(BOT_DRAG_MIME) + + return ( +
{ + if (accepts(event)) { + event.preventDefault() + setOver(true) + } + }} + onDragLeave={event => { + // Only clear when the pointer leaves the BLOCK, not when it crosses + // between the rows inside it — dragleave fires on every child + // boundary, which otherwise strobes the highlight. + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setOver(false) + } + }} + onDragOver={event => { + if (!accepts(event)) { + return + } + + // preventDefault is what MAKES this a drop target — without it the + // browser refuses the drop and the cursor stays "no entry". + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + + if (!over) { + setOver(true) + } + }} + onDrop={event => { + setOver(false) + // The dropped row remounts under its new section, so its own dragend + // never reaches the new node — clear the in-flight state here or the + // row stays faded after a successful drop. + $draggingBot.set(null) + + const key = event.dataTransfer.getData(BOT_DRAG_MIME) + + // No in-flight key means the user pressed Escape mid-drag: refuse. + if (!key || !dragging || isSource) { + return + } + + event.preventDefault() + onDropBot(key) + }} + > + {children} +
) } diff --git a/apps/desktop/src/plugins/hermes-bots/user-sections.test.ts b/apps/desktop/src/plugins/hermes-bots/user-sections.test.ts index e345f82306..a16ca4fccc 100644 --- a/apps/desktop/src/plugins/hermes-bots/user-sections.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/user-sections.test.ts @@ -1,51 +1,100 @@ -import { describe, expect, it } from 'vitest' +/** + * User sections — the three invariants that make membership-on-the-bot safe: + * filing persists through `saveBotMeta` (so it rides profile sync), every row + * lands in exactly one block with the remainder as Unassigned, and deleting a + * section returns its bots to Unassigned rather than losing them. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { saveBotMeta, storage } = vi.hoisted(() => ({ + saveBotMeta: vi.fn<(bot: { name: string }, patch: Record) => Promise>(), + storage: new Map() +})) + +vi.mock('./data', async () => { + const { atom } = await import('nanostores') + const $botMeta = atom>({}) + + saveBotMeta.mockImplementation(async (bot: { name: string }, patch: Record) => { + $botMeta.set({ ...$botMeta.get(), [bot.name]: { ...$botMeta.get()[bot.name], ...patch } }) + + return { serverOutcome: 'persisted', serverPersisted: true } + }) + + return { $botMeta, saveBotMeta } +}) + +vi.mock('./routing', () => ({ + botRosterMeta: (bot: { name: string }, meta: Record) => meta[bot.name] +})) + +vi.mock('./shared', () => ({ + getPluginCtx: () => ({ + storage: { + get: (key: string, fallback: unknown) => (storage.has(key) ? storage.get(key) : fallback), + set: (key: string, value: unknown) => storage.set(key, value) + } + }) +})) + +import { $botMeta } from './data' +import type { RosterRow } from './types' import { - botDragPayload, + $botSections, + createBotSection, + deleteBotSection, groupRowsBySection, - normalizeBotSections, - readBotDragPayload, + loadBotSections, + moveBotsToSection, UNASSIGNED_SECTION_KEY } from './user-sections' -const bot = (name: string) => ({ name }) as never +const bot = (name: string) => ({ name }) as RosterRow +const row = (name: string) => ({ bot: bot(name), kind: 'bot' as const }) -describe('user sections model', () => { - it('normalizes: drops blanks and duplicates, defaults a name, keeps only an explicit icon=false', () => { - const out = normalizeBotSections([ - { id: 'a', name: 'Clients' }, - { id: 'a', name: 'dupe' }, - { id: '', name: 'blank' }, - { id: 'b', name: ' ' }, - { id: 'c', name: 'Bare', icon: false }, - { id: 'd', name: 'On', icon: true }, - null, - 'junk' - ]) +beforeEach(() => { + storage.clear() + $botMeta.set({}) + $botSections.set([]) + saveBotMeta.mockClear() +}) - expect(out).toEqual([ - { id: 'a', name: 'Clients' }, - { id: 'b', name: 'Section' }, - { id: 'c', name: 'Bare', icon: false }, - { id: 'd', name: 'On' } - ]) +describe('user sections', () => { + it('filing writes one sectionId per bot through saveBotMeta and survives a reload', async () => { + const section = createBotSection('Clients', [bot('nanox'), bot('scout')])! + + // Membership rides the bot's own meta write (profile ui_meta), one per bot. + await vi.waitFor(() => expect(saveBotMeta).toHaveBeenCalledTimes(2)) + expect(saveBotMeta).toHaveBeenCalledWith(bot('nanox'), { sectionId: section.id }) + + // A no-op move (already there) writes nothing. + await moveBotsToSection([bot('nanox')], section.id) + expect(saveBotMeta).toHaveBeenCalledTimes(2) + + // The section record itself persists in plugin storage. + $botSections.set([]) + loadBotSections() + expect($botSections.get()).toEqual([{ id: section.id, name: 'Clients' }]) }) - it('groups every row exactly once, unknown sections fall to Unassigned, Unassigned is last', () => { - const rows = [ - { bot: bot('nanox'), kind: 'bot' }, - { bot: bot('scout'), kind: 'bot' }, - { bot: bot('ghost'), kind: 'bot' }, - { kind: 'group', name: 'Room' } - ] as never[] + it('groups every row exactly once; unknown or missing sections fall to Unassigned, drawn last', () => { + const rows = [row('nanox'), row('scout'), row('ghost'), { kind: 'group' as const, name: 'Room' }] const meta = { nanox: { sectionId: 'sec-clients' }, scout: { sectionId: 'sec-workforce' }, ghost: { sectionId: 'sec-deleted' } - } as never + } - const blocks = groupRowsBySection(rows, [{ id: 'sec-clients', name: 'Clients' }, { id: 'sec-workforce', name: 'Workforce' }], meta) + const blocks = groupRowsBySection( + rows, + [ + { id: 'sec-clients', name: 'Clients' }, + { id: 'sec-workforce', name: 'Workforce' } + ], + meta + ) expect(blocks.map(b => [b.key, b.rows.length])).toEqual([ ['section:sec-clients', 1], @@ -53,12 +102,22 @@ describe('user sections model', () => { [UNASSIGNED_SECTION_KEY, 2] ]) expect(blocks.flatMap(b => b.rows)).toHaveLength(rows.length) + expect(groupRowsBySection(rows, [], meta)).toEqual([{ id: null, key: UNASSIGNED_SECTION_KEY, name: '', rows }]) }) - it('drag payload round-trips and a foreign drop yields no keys', () => { - expect(readBotDragPayload(botDragPayload(['a', 'b']))).toEqual(['a', 'b']) - expect(readBotDragPayload('not json')).toEqual([]) - expect(readBotDragPayload(JSON.stringify({ nope: 1 }))).toEqual([]) - expect(readBotDragPayload(JSON.stringify(['ok', 3, '', null]))).toEqual(['ok']) + it('deleting a section returns its bots to Unassigned, and undo refiles them', async () => { + const section = createBotSection('Clients', [bot('nanox')])! + createBotSection('Team') + await vi.waitFor(() => expect($botMeta.get().nanox?.sectionId).toBe(section.id)) + + const { members, undo } = deleteBotSection(section.id, [bot('nanox'), bot('scout')]) + + expect(members).toEqual([bot('nanox')]) + expect($botSections.get().map(s => s.name)).toEqual(['Team']) + await vi.waitFor(() => expect($botMeta.get().nanox?.sectionId).toBeNull()) + + undo() + expect($botSections.get().map(s => s.name)).toEqual(['Clients', 'Team']) + await vi.waitFor(() => expect($botMeta.get().nanox?.sectionId).toBe(section.id)) }) }) diff --git a/apps/desktop/src/plugins/hermes-bots/user-sections.ts b/apps/desktop/src/plugins/hermes-bots/user-sections.ts index b995cffa82..d653475535 100644 --- a/apps/desktop/src/plugins/hermes-bots/user-sections.ts +++ b/apps/desktop/src/plugins/hermes-bots/user-sections.ts @@ -4,24 +4,21 @@ * 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 NanoX and MODE filed together under "Clients". + * want two client bots filed together under "Clients". * * So this is a SECOND axis, and it composes with the first rather than - * replacing it: the gateway sections still render exactly as they did whenever - * the roster is showing more than one connection, and user sections group the - * flat list underneath. Two deliberate choices, carried over from the branch - * this is ported from: + * 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, and - * it is where members of a deleted section land. It has no record, so its - * collapsed state keys off this literal. + * * "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 + two session atoms. No JSX — the pane composes it. + * Pure model + session atoms. No JSX — the pane composes it. */ import { atom } from 'nanostores' @@ -37,41 +34,15 @@ export const BOT_SECTIONS_KEY = 'bot-sections-v1' export interface BotSection { id: string name: string - /** Draw the folder glyph beside the name. Default on; a user who wants a - * bare list of names can turn it off per section. Optional so every - * section persisted before this existed still reads as "show it". */ - icon?: boolean } /** `[{ id, name }]`, in display order. */ export const $botSections = atom([]) -/** Roster keys the user has multi-selected (cmd/ctrl-click). Session-only: a - * selection is a gesture in progress, not a setting. */ -export const $botPicked = atom([]) - -/** The row a shift-click range extends FROM — the last plain click or the - * last end of a shift-range, mirroring how Finder/Mail anchor a range so a - * second shift-click re-anchors from where you are, not where you started. */ -export const $botPickAnchor = atom(null) - -/** - * The roster key of the bot being renamed in place, and the text in the field. - * - * MODULE state, not component state. It was `useState` inside `BotRow`, and - * double-click did nothing: opening a bot resolves its source and canonical - * chat, which changes `botRosterKey` — so the row REMOUNTS between the click - * and the double-click, and the flag was gone before it could paint. The - * handler fired every time; the state did not survive to the next render. - * (Verified in the running app: the console log landed, `data-renaming` was - * still "0".) Keying the caret outside the row is what makes it immune. - */ -export const $renamingBot = atom(null) -export const $renamingBotDraft = atom('') - -/** The section whose header is currently an editable name field. Session-only - * by nature: a rename in progress is a caret, not a setting. */ -export const $renamingSection = atom(null) +/** 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)) { @@ -85,109 +56,58 @@ export function normalizeBotSections(value: unknown): BotSection[] { const id = String((entry as BotSection)?.id || '').trim() const name = String((entry as BotSection)?.name || '').trim() - if (!id || seen.has(id)) { + if (!id || !name || seen.has(id)) { continue } seen.add(id) - out.push({ - id, - name: name || 'Section', - // Only ever stored as an explicit false — absent means on. - ...((entry as BotSection)?.icon === false ? { icon: false } : {}) - }) + out.push({ id, name }) } return out } -export function persistBotSections(next: unknown): Promise { - const value = normalizeBotSections(next) - - $botSections.set(value) +function persistBotSections(next: BotSection[]): void { + $botSections.set(next) try { - return Promise.resolve(getPluginCtx()?.storage?.set?.(BOT_SECTIONS_KEY, value)) - .then(() => undefined) - .catch(() => undefined) + 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. - return Promise.resolve() } } /** Read the persisted list back at plugin start. */ -/** - * The roster's three standing sections, with FIXED ids. - * - * Membership lives on each bot as `ui_meta.hermes-bots.sectionId`, which is a - * file in the profile — but the section RECORDS live in plugin storage, which - * is localStorage. Generated ids would mean the two halves could never be set - * up together from outside the app: a profile.yaml written by hand would point - * at a section id that does not exist, and the bot would silently land in - * Unassigned. Fixed ids are what make the pairing writable from either side. - * - * Seeding is ADDITIVE and idempotent: a section already present by id is left - * exactly as it is — including a rename, an icon setting, and its position — - * and anything the user made themselves is untouched. Deleting one of these on - * purpose is the one thing this cannot tell apart from never having had it, so - * a deleted standing section comes back on next load; renaming it is the way - * to make it yours. - */ -const SEEDED_SECTIONS: BotSection[] = [ - { id: 'sec-general', name: 'General' }, - { id: 'sec-workforce', name: 'Workforce' }, - { id: 'sec-clients', name: 'Clients' } -] - -export async function loadBotSections(): Promise { +export function loadBotSections(): void { try { - const stored = await Promise.resolve(getPluginCtx()?.storage?.get?.(BOT_SECTIONS_KEY, [])) - const list = normalizeBotSections(stored) - const seeded = withSeededSections(list) - - $botSections.set(seeded) - - // Only write back when seeding actually added something, so an ordinary - // load stays a read. - if (seeded.length !== list.length) { - void persistBotSections(seeded) - } + $botSections.set(normalizeBotSections(getPluginCtx()?.storage?.get?.(BOT_SECTIONS_KEY, []))) } catch { - $botSections.set(normalizeBotSections(SEEDED_SECTIONS)) + $botSections.set([]) } } - -function withSeededSections(list: BotSection[]): BotSection[] { - const known = new Set(list.map(section => section.id)) - const missing = SEEDED_SECTIONS.filter(section => !known.has(section.id)) - - // Seeded sections lead, in their declared order, so a fresh roster reads - // General / Workforce / Clients rather than in load order. - return missing.length ? [...missing, ...list] : list -} - function newSectionId(): string { return `sec-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}` } -/** Create a section and move `bots` into it. Returns the new section. */ -export function createBotSection(name: string, bots: RosterRow[] = []): BotSection { - const section: BotSection = { id: newSectionId(), name: String(name || '').trim() || 'New section' } +/** 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() - void persistBotSections([...$botSections.get(), section]) - moveBotsToSection(bots, section.id) + if (!clean) { + return null + } + + const section: BotSection = { id: newSectionId(), name: clean } + + persistBotSections([...$botSections.get(), section]) + void moveBotsToSection(bots, section.id) return section } -/** Show or hide the folder glyph on one section's heading. */ -export function setBotSectionIcon(id: string, icon: boolean): void { - void persistBotSections($botSections.get().map(s => (s.id === id ? { ...s, icon } : s))) -} - export function renameBotSection(id: string, name: string): void { const clean = String(name || '').trim() @@ -195,18 +115,38 @@ export function renameBotSection(id: string, name: string): void { return } - void persistBotSections($botSections.get().map(s => (s.id === id ? { ...s, name: clean } : s))) + 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. */ -export function deleteBotSection(id: string, roster: RosterRow[] = []): void { - void persistBotSections($botSections.get().filter(s => s.id !== id)) - moveBotsToSection( - (roster || []).filter(bot => botSectionId(bot, $botMeta.get()) === id), - null - ) +/** + * 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 { @@ -222,14 +162,19 @@ export function moveBotSection(id: string, delta: number): void { const [moved] = next.splice(from, 1) next.splice(to, 0, moved!) - void persistBotSections(next) + persistBotSections(next) } -/** `null` clears the assignment (back to Unassigned). */ -export function moveBotsToSection(bots: RosterRow[], sectionId: null | string): void { +/** + * `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) { - void saveBotMeta(bot, { sectionId: sectionId || null }) + if (bot && botSectionId(bot, $botMeta.get()) !== (sectionId || null)) { + await saveBotMeta(bot, { sectionId: sectionId || null }) } } } @@ -281,16 +226,16 @@ export function groupRowsBySection rows: byId.get(section.id) || [] })) - blocks.push({ id: null, key: UNASSIGNED_SECTION_KEY, name: 'Unassigned', rows: loose }) + 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 heading, which is the gesture -// people reach for first and the one the context menu's "Move to section…" was -// standing in for. +// 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 @@ -299,28 +244,4 @@ export function groupRowsBySection // 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-keys' - -/** Roster keys in flight during a drag. Session-only, and cleared on dragend - * even when the drop lands outside any target — a stuck "dragging" highlight - * outlives the gesture and reads as a broken pane. */ -export const $draggingBots = atom([]) - -/** Keys being dragged, as a payload string. Multi-select drags the whole - * selection when the dragged row is part of it — same rule as the section - * context menu's `targets()`. */ -export function botDragPayload(keys: string[]): string { - return JSON.stringify(keys) -} - -/** Read the payload back on drop. Never throws: a foreign or malformed drop - * yields no keys and the drop is simply ignored. */ -export function readBotDragPayload(raw: string): string[] { - try { - const parsed: unknown = JSON.parse(raw) - - return Array.isArray(parsed) ? parsed.filter((k): k is string => typeof k === 'string' && Boolean(k)) : [] - } catch { - return [] - } -} +export const BOT_DRAG_MIME = 'application/x-hermes-bot-key' diff --git a/contributors/emails/michaelalexanderknaap@gmail.com b/contributors/emails/michaelalexanderknaap@gmail.com new file mode 100644 index 0000000000..47b5623e70 --- /dev/null +++ b/contributors/emails/michaelalexanderknaap@gmail.com @@ -0,0 +1 @@ +fortun8te diff --git a/website/docs/user-guide/bot-mode.md b/website/docs/user-guide/bot-mode.md index 7170fa3aab..b529b7daa0 100644 --- a/website/docs/user-guide/bot-mode.md +++ b/website/docs/user-guide/bot-mode.md @@ -26,6 +26,17 @@ The roster shows one row per agent profile: avatar, latest-message preview, and Typing `/new` (or `/reset`) inside a Bot's canonical chat would fork the relationship into a scratch session — the one thing Bot Mode promises never happens. The composer reroutes it to `/compact` instead: fresh working context, same conversation. Regular sessions on the same profile keep full `/new` freedom. ::: +### Organize bots into sections + +Sections are folders you make yourself — **Clients**, **Team**, whatever fits — as a second axis beside the automatic per-gateway grouping. With no sections created the roster is the plain list it always was. + +- **Create one** from the pane's **+** menu → **New section**, or right-click a Bot → **Move to section** → **New section…** (that files the Bot into it as you create it). +- **File a Bot** by dragging its row onto a section — the target highlights while you hover, and **Esc** cancels the drag — or right-click → **Move to section** and pick one. **Remove from section** puts it back in **Unassigned**. +- **Rename, reorder, or delete** a section from its heading's right-click menu (or the **⋯** that appears on hover); double-click a heading to rename. Headings fold like the gateway headings do. +- **Deleting a section never deletes Bots** — they return to **Unassigned**, and the toast offers **Undo**. No confirmation is asked. + +Membership is stored in each Bot's profile metadata (`ui_meta`), so a Bot's section follows it to every desktop connected to that backend. When the roster shows more than one gateway, sections nest inside each gateway's bucket. + ## Creating a Bot Hit **New Agent** in the roster. The quick path is three fields — **Name**, **Title**, **Description** — and the Bot exists in seconds, introducing itself as the first message of its new Bot Chat.