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 ? (
)}
)
@@ -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 (
+
+ )
+}
+
+// ── 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 = (
+
+
+
- )}
- {/* Unassigned has no record to rename, reorder or delete — it is
- whatever is left over — so it gets no menu rather than a menu of
- disabled items. */}
- {id && !renaming ? (
-
-
-
-
-
- {
- 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}
+
+
- ) : (
- 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.