feat(desktop): polish bot roster sections — dialog rename, Undo delete, Esc-cancel drag, nested under gateways (salvage #100745)

Follow-up on @fortun8te's user-made roster sections:

- Sections start empty: no seeded General/Workforce/Clients. With no
  sections created the roster renders exactly as before.
- New section and Rename go through one Dialog + Input + Cancel/Save
  (the app's session-rename shape) instead of an inline caret; the row
  menu's "New section…" files the bot as it creates.
- Delete needs no confirmation: bots return to Unassigned and the toast
  offers Undo (restores the section in its slot and refiles its bots).
- Drag: single-row drag under a private MIME type, every valid target
  shows a faint outline while a drag is live, the hovered target lights
  up, the source section refuses the drop, Escape cancels, and the moved
  row no longer stays faded after it remounts under its new section.
- Multi-select (cmd/shift-click, querySelectorAll shift-range) dropped:
  the roster has no selection model. Per-bot saveBotMeta writes run in
  sequence, one per profile (membership IS a field on each profile).
- Section heading reuses RosterSectionHeader (gains `action` /
  `onDoubleClick`), so user sections fold and look like the gateway
  headings; ⋯ menu and right-click drive the same Rename / Move up /
  Move down / Delete. Empty sections show a dashed "Drag bots here" slot.
- Composes with gateway buckets: sections nest INSIDE each connection
  bucket, indented under a hairline rail (membership lives in the bot's
  profile on that gateway); empty sections repeat there only mid-drag.
- Full i18n parity (en / ja / zh / zh-hant) for every new string; icon
  toggle and the storage-async plumbing removed.
- Tests trimmed to the three invariants (membership persists through
  saveBotMeta + reload, remainder = Unassigned, delete returns bots +
  undo) plus a live Electron e2e covering the whole flow.
- Docs: "Organize bots into sections" in user-guide/bot-mode.md.
This commit is contained in:
Teknium
2026-09-02 05:16:23 -07:00
parent bd9955d529
commit e9dd0bf5d5
12 changed files with 984 additions and 505 deletions

View File

@@ -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=<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<void> {
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<void> {
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<Array<[string, string[]]>> {
return roster(page).locator('[data-slot="bots-section"]').evaluateAll(blocks =>
blocks.map(block => [
block.querySelector('button[aria-expanded] span.truncate')?.textContent?.trim() ?? '',
[...block.querySelectorAll<HTMLElement>('[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()
})

View File

@@ -59,7 +59,7 @@ vi.mock('./roster-actions', () => ({ openRosterBot }))
const noop = () => undefined
function renderRow(bot: RosterRow) {
render(<BotRow bot={bot} onDelete={noop} onEdit={noop} onGroup={noop} />)
render(<BotRow bot={bot} onDelete={noop} onEdit={noop} onGroup={noop} onNewSection={noop} />)
return screen.getByRole('button')
}

View File

@@ -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 = (
<RowButton
aria-label={rowTooltip}
@@ -292,21 +229,18 @@ export function BotRow({ bot, onDelete, onEdit, onGroup, showHandle }: BotRowPro
'flex w-full min-w-0 max-w-full items-center gap-2.5 overflow-hidden rounded-md px-2 py-2 text-left transition-colors',
'hover:bg-(--chrome-action-hover)',
isActive && 'bg-(--ui-row-active-background)',
isPicked && 'bg-(--ui-row-active-background) ring-1 ring-(--ui-accent)/60'
// The row being dragged fades in place; the browser's drag image is
// the row itself, so the ghost under the pointer is the full row.
dragging && 'opacity-40'
)}
data-roster-key={rosterKey}
draggable
onClick={onRowClick}
onDragEnd={() => $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. */}
<ContextMenuSub>
<ContextMenuSubTrigger>
{isPicked && picked.length > 1 ? `Move ${picked.length} bots to…` : 'Move to section…'}
</ContextMenuSubTrigger>
<ContextMenuSubTrigger>{b.sections.moveTo}</ContextMenuSubTrigger>
<ContextMenuSubContent>
{sections.map(section => (
<ContextMenuItem
disabled={section.id === currentSectionId}
key={section.id}
onSelect={() => {
moveBotsToSection(targets(), section.id)
$botPicked.set([])
}}
onSelect={() => void moveBotsToSection([bot], section.id)}
>
<Codicon className="mr-1.5" name="folder" />
{section.name}
</ContextMenuItem>
))}
{sections.length ? <ContextMenuSeparator /> : null}
<ContextMenuItem
onSelect={() => {
const section = createBotSection('New section', targets())
$botPicked.set([])
$renamingSection.set(section.id)
}}
>
New section…
<ContextMenuItem onSelect={() => onNewSection(bot)}>
<Codicon className="mr-1.5" name="new-folder" />
{b.sections.newSectionEllipsis}
</ContextMenuItem>
{currentSectionId ? (
<ContextMenuItem
onSelect={() => {
moveBotsToSection(targets(), null)
$botPicked.set([])
}}
>
Unassigned
<ContextMenuItem onSelect={() => void moveBotsToSection([bot], null)}>
<Codicon className="mr-1.5" name="inbox" />
{b.sections.removeFromSection}
</ContextMenuItem>
) : null}
</ContextMenuSubContent>

View File

@@ -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: '編輯設定檔',

View File

@@ -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()

View File

@@ -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<GatewaySource[]>([])
@@ -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 | (RosterRow & { path?: string })>(null)
const [deletingGroup, setDeletingGroup] = useState<null | { members: GroupMember[]; name: string }>(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 | string>(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 | RosterRow>(null)
const [query, setQuery] = useState('')
const [rowKindFilter, setRowKindFilter] = useState<RosterKindFilter>('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 (
<div
className={cn(
'min-w-0 rounded-md transition-colors',
dropTarget === key && 'bg-(--ui-accent)/10 ring-1 ring-(--ui-accent)/50'
)}
<SectionDropZone
isSource={Boolean(dragging) && block.rows.some(row => 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)
}
}}
>
<UserSectionHeader
canMoveDown={order >= 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 ? (
<div className="grid min-w-0 gap-0.5">
{block.rows.map(row =>
row.kind === 'group' ? renderGroupRow(row) : renderBotRow(row.bot, `${key}:`)
)}
</div>
) : (
// Empty section: a quiet dashed slot that says what it is for,
// and doubles as a roomy drop target.
<div className="mx-1 mb-1 rounded-md border border-dashed border-(--ui-stroke-secondary) px-2 py-2 text-center text-[0.6875rem] text-(--ui-text-quaternary)">
{b.sections.emptyHint}
</div>
)}
</div>
</SectionDropZone>
)
})
)
@@ -671,7 +683,9 @@ export function BotsPane() {
option={section.option}
/>
{collapsed ? null : (
<div className="grid min-w-0 gap-0.5">{section.rows.map(row => renderBotRow(row.bot, `${section.id}:`))}</div>
<div className="grid min-w-0 gap-0.5">
{renderUserSections(section.rows, `${section.id}:`)}
</div>
)}
</div>
)
@@ -750,17 +764,10 @@ export function BotsPane() {
<Codicon className="mr-1.5" name="organization" />
{b.group.newTitle}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
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)
}}
>
<Codicon className="mr-1.5" name="folder" />
New section
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setSectionDialog({ mode: 'create' })}>
<Codicon className="mr-1.5" name="new-folder" />
{b.sections.newSection}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -936,7 +943,7 @@ export function BotsPane() {
sortedGroupRows.length ? renderGroupChatSection() : null,
...gatewaySections.sections.map(renderGatewaySection)
].filter(Boolean)
: renderUserSections()}
: renderUserSections(rosterRows)}
{showHiddenSection ? (
<div
className="mt-1 border-t border-(--ui-stroke-tertiary) pt-1"
@@ -991,6 +998,23 @@ export function BotsPane() {
// registered connections — their turns route to their own machines.
roster={roster}
/>
<SectionNameDialog
initialName={sectionDialog?.mode === 'rename' ? sectionDialog.name : ''}
mode={sectionDialog?.mode === 'rename' ? 'rename' : 'create'}
onOpenChange={open => {
if (!open) {
setSectionDialog(null)
}
}}
onSubmit={name => {
if (sectionDialog?.mode === 'rename') {
renameBotSection(sectionDialog.id, name)
} else {
createBotSection(name, sectionDialog?.bot ? [sectionDialog.bot] : [])
}
}}
open={Boolean(sectionDialog)}
/>
<EditProfileDialog
bot={editing}
onClose={() => {

View File

@@ -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 = (
<RowButton
aria-expanded={!collapsed}
className="mt-1 flex w-full min-w-0 items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-[0.6875rem] font-semibold uppercase tracking-wider text-(--ui-text-quaternary) transition-colors hover:bg-(--chrome-action-hover) hover:text-(--ui-text-secondary)"
className={cn(
'flex w-full min-w-0 items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-[0.6875rem] font-semibold uppercase tracking-wider text-(--ui-text-quaternary) transition-colors hover:bg-(--chrome-action-hover) hover:text-(--ui-text-secondary)',
action ? 'flex-1' : 'mt-1'
)}
onClick={onToggle}
onDoubleClick={onDoubleClick}
>
<DisclosureCaret open={!collapsed} />
{gatewayKind ? (
@@ -269,7 +280,18 @@ export function RosterSectionHeader({
</RowButton>
)
return tip ? <Tip label={tip}>{button}</Tip> : button
const heading = tip ? <Tip label={tip}>{button}</Tip> : 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 ? (
<div className="group/section mt-1 flex w-full min-w-0 items-center gap-1 pr-1">
{heading}
{action}
</div>
) : (
heading
)
}
interface GatewaySectionHeadingProps {

View File

@@ -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<HTMLInputElement>(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 (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{mode === 'create' ? b.sections.newTitle : b.sections.renameTitle}</DialogTitle>
</DialogHeader>
<Input
aria-label={b.sections.nameLabel}
autoFocus
maxLength={40}
onChange={event => setValue(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault()
submit()
}
}}
placeholder={b.sections.namePlaceholder}
ref={inputRef}
value={value}
/>
<DialogFooter>
<Button onClick={() => onOpenChange(false)} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button disabled={!value.trim()} onClick={submit} type="button">
{mode === 'create' ? b.sections.create : t.common.save}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ── 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 (
<RosterSectionHeader
collapsed={collapsed}
count={count}
icon="inbox"
label={b.sections.unassigned}
onToggle={onToggle}
/>
)
}
// 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 ? (
<ContextMenuContent>
<ContextMenuItem
onSelect={() => {
setDraft(name)
$renamingSection.set(id)
}}
>
Rename
</ContextMenuItem>
<ContextMenuItem onSelect={() => setBotSectionIcon(id, !showIcon)}>
{showIcon ? 'Hide icon' : 'Show icon'}
</ContextMenuItem>
<ContextMenuItem onSelect={() => onMove(-1)}>Move up</ContextMenuItem>
<ContextMenuItem onSelect={() => onMove(1)}>Move down</ContextMenuItem>
<ContextMenuItem onSelect={onDelete} variant="destructive">
Delete section (keeps its bots)
</ContextMenuItem>
</ContextMenuContent>
) : 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 = (
<div className="group/section mt-1 flex w-full min-w-0 items-center gap-1 pr-1">
{renaming ? (
<input
autoFocus
className="ml-2 min-w-0 flex-1 rounded-[3px] border border-(--ui-stroke-secondary) bg-(--ui-bg-elevated) px-1 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider outline-none"
onBlur={commit}
onChange={event => 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}
/>
) : (
<RowButton
aria-expanded={!collapsed}
className={cn(
'flex min-w-0 flex-1 items-center gap-1.5 rounded-md px-2 py-1.5 text-left',
'text-[0.6875rem] font-semibold uppercase tracking-wider text-(--ui-text-quaternary)',
'transition-colors hover:bg-(--chrome-action-hover) hover:text-(--ui-text-secondary)'
)}
onClick={onToggle}
onDoubleClick={() => {
if (id) {
setDraft(name)
$renamingSection.set(id)
}
}}
const action = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label={b.sections.options(name)}
className="shrink-0 rounded-md p-0.5 text-(--ui-text-quaternary) opacity-0 transition hover:text-foreground group-hover/section:opacity-100 focus-visible:opacity-100 data-[state=open]:opacity-100"
type="button"
>
<DisclosureCaret open={!collapsed} />
{showIcon ? <Codicon className="shrink-0" name={id ? 'folder' : 'inbox'} /> : null}
<span className="min-w-0 truncate">{name}</span>
<span aria-hidden className="min-w-0 flex-1" />
<span className="shrink-0 font-normal tabular-nums">{count}</span>
</RowButton>
)}
{/* 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 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label={`${name} section options`}
className="shrink-0 rounded-md p-0.5 text-(--ui-text-quaternary) opacity-0 transition hover:text-foreground group-hover/section:opacity-100 focus-visible:opacity-100"
type="button"
>
<Codicon name="ellipsis" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => {
setDraft(name)
$renamingSection.set(id)
}}
>
Rename
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setBotSectionIcon(id, !showIcon)}>
{showIcon ? 'Hide icon' : 'Show icon'}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onMove(-1)}>Move up</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onMove(1)}>Move down</DropdownMenuItem>
{/* Deleting a section keeps every bot in it — they fall back to
Unassigned. Said plainly here so nobody has to find out. */}
<DropdownMenuItem onSelect={onDelete} variant="destructive">
Delete section (keeps its bots)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
<Codicon name="ellipsis" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{items.map(item => (
<DropdownMenuItem disabled={item.disabled} key={item.label} onSelect={item.onSelect}>
<Codicon className="mr-1.5" name={item.icon} />
{item.label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onDelete} variant="destructive">
<Codicon className="mr-1.5" name="trash" />
{t.common.delete}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
return sectionMenu ? (
return (
<ContextMenu>
<ContextMenuTrigger asChild>{header}</ContextMenuTrigger>
{sectionMenu}
<ContextMenuTrigger asChild>
<div data-section-id={id} data-slot="bots-section-heading">
<RosterSectionHeader
action={action}
collapsed={collapsed}
count={count}
icon="folder"
label={name}
onDoubleClick={onRename}
onToggle={onToggle}
tip={b.sections.headingTip}
/>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{items.map(item => (
<ContextMenuItem disabled={item.disabled} key={item.label} onSelect={item.onSelect}>
{item.label}
</ContextMenuItem>
))}
<ContextMenuSeparator />
<ContextMenuItem onSelect={onDelete} variant="destructive">
{t.common.delete}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
) : (
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 (
<div
className={cn(
'relative min-w-0 rounded-md transition-[background-color,box-shadow] duration-100',
nested && 'ml-2.5 border-l border-(--ui-stroke-tertiary) pl-1',
// While a drag is live, every valid target gets a faint outline so the
// user can see where a drop is allowed before hovering one.
armed && 'ring-1 ring-inset ring-(--ui-stroke-secondary)',
lit && 'bg-(--ui-accent)/10 ring-(--ui-accent)'
)}
data-drop-over={lit ? 'true' : undefined}
data-slot="bots-section"
onDragEnter={event => {
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}
</div>
)
}

View File

@@ -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<string, unknown>) => Promise<unknown>>(),
storage: new Map<string, unknown>()
}))
vi.mock('./data', async () => {
const { atom } = await import('nanostores')
const $botMeta = atom<Record<string, { sectionId?: null | string }>>({})
saveBotMeta.mockImplementation(async (bot: { name: string }, patch: Record<string, unknown>) => {
$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<string, unknown>) => 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))
})
})

View File

@@ -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<BotSection[]>([])
/** 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<string[]>([])
/** 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 | string>(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 | string>(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 | string>(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 | string>(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<void> {
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<void> {
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<void> {
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<TRow extends { bot?: RosterRow } | RosterRow>
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<TRow extends { bot?: RosterRow } | RosterRow>
// 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<string[]>([])
/** 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'

View File

@@ -0,0 +1 @@
fortun8te

View File

@@ -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.