diff --git a/changelog.d/452-tree-reorg-move-not-rename.fixed.md b/changelog.d/452-tree-reorg-move-not-rename.fixed.md new file mode 100644 index 000000000..a33da6775 --- /dev/null +++ b/changelog.d/452-tree-reorg-move-not-rename.fixed.md @@ -0,0 +1 @@ +Tree reorganization moves / reparents existing Bots; it never renames them to `归档-` and recreates. Archived Bots are hidden from the default Bot list. A one-shot repair restores leftover `归档-*` / `Archive-*` names. diff --git a/core/client.js b/core/client.js index de58fe6d0..ef823a0f5 100644 --- a/core/client.js +++ b/core/client.js @@ -6765,7 +6765,8 @@ function TeamSidebar(props) { const [collapsed, setCollapsed] = (0, import_react10.useState)({}); const toggle = (id) => setCollapsed((value) => ({ ...value, [id]: !value[id] })); const inTree = (entry) => entry.kind === "bot" && !entry.item.remote; - const tree = structure && wide && main ? botTree([main, ...order.filter(inTree).map((entry) => entry.item)], main.id, (id) => structure.parentOf(id)) : null; + const listed = (entry) => inTree(entry) && structure?.include?.(entry.item.id) !== false; + const tree = structure && wide && main ? botTree([main, ...order.filter(listed).map((entry) => entry.item)], main.id, (id) => structure.parentOf(id)) : null; useListGlide(listRef, wide, order.map((entry) => entry.item.id).join(",")); useLandOnMainBot(roster, useSessions, actions); const rowProps = { roster, actions, useSessions, useUi, useSessionStatus: props.useSessionStatus, useSessionRetainInfo: props.useSessionRetainInfo, useActivity: props.useActivity, useWork: props.useWork, wide }; @@ -6796,7 +6797,7 @@ function TeamSidebar(props) { "div", { ref: listRef, className: "bt-list", role: "region", "aria-label": t("Bot list") }, tree ? (0, import_react10.createElement)("div", { className: "bt-tree", role: "group", "aria-label": t("Bot tree") }, tree.map((node) => (0, import_react10.createElement)(TreeNode, { key: node.bot.id, node, rowProps, markedUnread, collapsed, toggle, actions: structure?.nodeActions }))) : main ? (0, import_react10.createElement)(BotRow, { ...rowProps, bot: main, marked: markedUnread[main.id] === true }) : roster.ready ? null : (0, import_react10.createElement)("div", { className: "bt-hint" }, t("Setting up your Main Bot…")), - order.filter((entry) => !(tree && inTree(entry))).map((entry) => entry.kind === "bot" ? (0, import_react10.createElement)(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === "room" ? (0, import_react10.createElement)(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) : (0, import_react10.createElement)(AgentRow, { key: entry.item.id, ...rowProps, agent: entry.item, marked: markedUnread[entry.item.id] === true })), + order.filter((entry) => !(tree && inTree(entry)) && !(inTree(entry) && structure?.include?.(entry.item.id) === false)).map((entry) => entry.kind === "bot" ? (0, import_react10.createElement)(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === "room" ? (0, import_react10.createElement)(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) : (0, import_react10.createElement)(AgentRow, { key: entry.item.id, ...rowProps, agent: entry.item, marked: markedUnread[entry.item.id] === true })), wide ? (0, import_react10.createElement)(FlyBotSections, { roster, actions, renderBot: (bot) => (0, import_react10.createElement)(BotRow, { key: bot.id, ...rowProps, bot, asRow: true, marked: markedUnread[bot.id] === true }) }) : null ) ); diff --git a/core/src/client/flybot-slots.js b/core/src/client/flybot-slots.js index 1a0de2607..330bef6dd 100644 --- a/core/src/client/flybot-slots.js +++ b/core/src/client/flybot-slots.js @@ -4,7 +4,8 @@ // ds-bot's own components render whatever is registered: // // flybot.sidebar.section { id, order, component({ roster, actions, call }) } below the Bot list -// flybot.sidebar.structure { id, order, parentOf(botId), menuItems?, nodeActions? } the Bot list becomes one tree under the Main Bot; +// flybot.sidebar.structure { id, order, parentOf(botId), include?(botId), menuItems?, nodeActions? } the Bot list becomes one tree under the Main Bot; +// include(botId) === false hides that Bot from the default list (e.g. tree-archived); // menuItems({ bot }) → [{ id, label, visible, run({ bot }) }] adds items to a Bot's right-click // menu (visible false hides one; a string from run shows as a toast; a throw as the error toast); // nodeActions({ bot }) is drawn after each tree row diff --git a/core/src/client/sidebar.js b/core/src/client/sidebar.js index a0bfc284c..0f7182b2c 100644 --- a/core/src/client/sidebar.js +++ b/core/src/client/sidebar.js @@ -358,8 +358,10 @@ export function TeamSidebar(props) { const [collapsed, setCollapsed] = useState({}) const toggle = id => setCollapsed(value => ({ ...value, [id]: !value[id] })) // Only own Bots belong to the tree; a friend's Bot stays in the ordered list below it. + // structure.include(id) === false hides a Bot from the default list (e.g. tree-archived, #452). const inTree = entry => entry.kind === 'bot' && !entry.item.remote - const tree = structure && wide && main ? botTree([main, ...order.filter(inTree).map(entry => entry.item)], main.id, id => structure.parentOf(id)) : null + const listed = entry => inTree(entry) && structure?.include?.(entry.item.id) !== false + const tree = structure && wide && main ? botTree([main, ...order.filter(listed).map(entry => entry.item)], main.id, id => structure.parentOf(id)) : null useListGlide(listRef, wide, order.map(entry => entry.item.id).join(',')) useLandOnMainBot(roster, useSessions, actions) const rowProps = { roster, actions, useSessions, useUi, useSessionStatus: props.useSessionStatus, useSessionRetainInfo: props.useSessionRetainInfo, useActivity: props.useActivity, useWork: props.useWork, wide } @@ -383,7 +385,7 @@ export function TeamSidebar(props) { h('div', { ref: listRef, className: 'bt-list', role: 'region', 'aria-label': t('Bot list') }, tree ? h('div', { className: 'bt-tree', role: 'group', 'aria-label': t('Bot tree') }, tree.map(node => h(TreeNode, { key: node.bot.id, node, rowProps, markedUnread, collapsed, toggle, actions: structure?.nodeActions }))) : main ? h(BotRow, { ...rowProps, bot: main, marked: markedUnread[main.id] === true }) : roster.ready ? null : h('div', { className: 'bt-hint' }, t('Setting up your Main Bot…')), - order.filter(entry => !(tree && inTree(entry))).map(entry => (entry.kind === 'bot' + order.filter(entry => !(tree && inTree(entry)) && !(inTree(entry) && structure?.include?.(entry.item.id) === false)).map(entry => (entry.kind === 'bot' ? h(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === 'room' ? h(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index a9d4d83d1..db76f2731 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -139,6 +139,7 @@ export function install(rt) { systemPrompt: bot.systemPrompt ?? '', fields: clone(bot.fields ?? {}), ...(typeof bot.color === 'string' ? { color: bot.color } : {}), ...(bot.avatar && typeof bot.avatar === 'object' ? { avatar: clone(bot.avatar) } : {}), + ...(bot.hidden === true ? { hidden: true } : {}), }) const persist = () => rt.save().catch(error => warn('flybot: save failed: %s', error)) @@ -605,6 +606,18 @@ export function install(rt) { }) } + /** Rename and/or hide a Bot in the sidebar. Used by plugins (e.g. tree archive, #452). */ + async function updateBotFields(id, patch = {}) { + const bot = rt.botOf(id) + if (bot === undefined) throw new Error(`flybot: unknown Bot ${id}`) + const name = patch.name !== undefined ? String(patch.name).trim() : undefined + const wantName = name !== undefined && name !== bot.name + const wantHidden = typeof patch.hidden === 'boolean' && Boolean(patch.hidden) !== Boolean(bot.hidden) + if (wantName) await rt.updateBot(id, { name }) + if (wantHidden) await rt.setFlags(id, { hidden: Boolean(patch.hidden) }) + return view(rt.botOf(id)) + } + async function send({ from, to, text, role = 'request', hop = 0, via = SHARED, meta, attachments } = {}) { await rt.load() if (typeof text !== 'string' || text.trim() === '') throw new Error('flybot: send needs text') @@ -938,6 +951,7 @@ export function install(rt) { hasAnyGrant: id => rt.hasAnyGrant?.(id) === true, of: sessionId => view(rt.botOf(rt.selfOf(sessionId))), create: spec => create({ plugin, ...spec }), + update: (id, patch) => updateBotFields(id, patch), setSystemPrompt, // Same object as flybot.fields. fields, diff --git a/core/test/new-chat.test.js b/core/test/new-chat.test.js index 4332cabeb..a5d2cc120 100644 --- a/core/test/new-chat.test.js +++ b/core/test/new-chat.test.js @@ -61,8 +61,9 @@ test('the sidebar + menu wires plugin actions in after New group chat', () => { assert.match(sidebar, /Number\(foreign\(a\)\) - Number\(foreign\(b\)\)/) // With @flybot/tree on, a friend's Bot is not a tree node under Main; it stays in the flat list. assert.match(sidebar, /const inTree = entry => entry\.kind === 'bot' && !entry\.item\.remote/) - assert.match(sidebar, /botTree\(\[main, \.\.\.order\.filter\(inTree\)/) - assert.match(sidebar, /order\.filter\(entry => !\(tree && inTree\(entry\)\)\)/) + assert.match(sidebar, /structure\?\.include\?\.\(entry\.item\.id\) !== false/) + assert.match(sidebar, /botTree\(\[main, \.\.\.order\.filter\(listed\)/) + assert.match(sidebar, /order\.filter\(entry => !\(tree && inTree\(entry\)\) && !\(inTree\(entry\) && structure\?\.include/) assert.equal(DICTIONARY.zh['Copy my handle'], '复制我的 handle') assert.equal(DICTIONARY.zh['Face-to-face'], '面对面') assert.equal(DICTIONARY.zh['Face-to-face room'], '面对面房间') diff --git a/core/types/flybot.d.ts b/core/types/flybot.d.ts index 1ce76346c..aec7f3e16 100644 --- a/core/types/flybot.d.ts +++ b/core/types/flybot.d.ts @@ -42,6 +42,8 @@ export interface BotView { color?: string /** Owner-chosen avatar ({ shape }, { image }, or { emoji }). Omitted when unset. */ avatar?: { shape?: string; image?: string; emoji?: string } + /** True when hidden from the default Bot list (Search still finds it). */ + hidden?: boolean } /** 'change' payload. Delivered for the listener's own plugin unless it subscribed with { all: true }. */ @@ -334,6 +336,8 @@ export interface FlyBot { of(sessionId: string): BotView | undefined /** Returns the new Bot's id. */ create(spec: CreateBotSpec): Promise + /** Rename and/or set sidebar flags. Plugins use this to hide archived Bots (#452). */ + update(id: BotId, patch: { name?: string; hidden?: boolean }): Promise setSystemPrompt(id: BotId, text: string): void /** Same object as FlyBot.fields. */ fields: BotFields diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index edb8e49ee..27e1a021d 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -92,7 +92,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second ### One sidebar list -- `TeamSidebar` lists local Bots, listed remote chats (`isListedRemoteChat`), live face-to-face rooms, and shared rooms in one `bt-list`. Own chats sort before foreign ones (a remote Bot, a face room, or `sharedFrom`); pinned and recency still win. A friend's Bot keeps the globe `RemoteMark`; a live face room gets a small people `FaceMark` (`bt-row-remote bt-row-face`). No row wraps its title (#384): a friend's Bot row shows the Bot's name (`bt-row-title`), then its owner (`bt-row-owner`, muted, shrinks first) and the globe (at `--bt-ink-2`, readable) on one line; its second line is the preview like an own row, or *Friend's Bot* before the first message, and the full `remoteBotTitle` ("Helper (Bob)") is its hover text and label. The row wrap's gear is absolutely positioned so the hover/selected band stays full width (#384). A friend's Bot fallback look (`lookAvoidingLocals`) steps its seeded shape (then color) when it would match one of your own Bots exactly; a named ink and its published hex count as one color. `indexRoster` registers the own Bots (`setLocalBots`) and `BotMark` applies the avoidance to every remote Bot, so the sidebar, header, palette, details drawer and transcript agree; a published image/emoji/shape is left alone. A face-room row stays on one line with an ellipsis, and every room row has its full title as hover text (#304). Past/ended face rooms stay out of this list (`isPastFaceRoom`) so the social section can fold them under **Past rooms**. With `@flybot/tree` on, only own Bots are tree nodes under Main; a friend's Bot stays in the flat ordered list (not hidden when Main is collapsed). +- `TeamSidebar` lists local Bots, listed remote chats (`isListedRemoteChat`), live face-to-face rooms, and shared rooms in one `bt-list`. Own chats sort before foreign ones (a remote Bot, a face room, or `sharedFrom`); pinned and recency still win. A friend's Bot keeps the globe `RemoteMark`; a live face room gets a small people `FaceMark` (`bt-row-remote bt-row-face`). No row wraps its title (#384): a friend's Bot row shows the Bot's name (`bt-row-title`), then its owner (`bt-row-owner`, muted, shrinks first) and the globe (at `--bt-ink-2`, readable) on one line; its second line is the preview like an own row, or *Friend's Bot* before the first message, and the full `remoteBotTitle` ("Helper (Bob)") is its hover text and label. The row wrap's gear is absolutely positioned so the hover/selected band stays full width (#384). A friend's Bot fallback look (`lookAvoidingLocals`) steps its seeded shape (then color) when it would match one of your own Bots exactly; a named ink and its published hex count as one color. `indexRoster` registers the own Bots (`setLocalBots`) and `BotMark` applies the avoidance to every remote Bot, so the sidebar, header, palette, details drawer and transcript agree; a published image/emoji/shape is left alone. A face-room row stays on one line with an ellipsis, and every room row has its full title as hover text (#304). Past/ended face rooms stay out of this list (`isPastFaceRoom`) so the social section can fold them under **Past rooms**. With `@flybot/tree` on, only own Bots are tree nodes under Main; a friend's Bot stays in the flat ordered list (not hidden when Main is collapsed). The structure entry may set `include(botId) === false` to omit a Bot from the default list (tree-archived and leftover `归档-*` / `Archive-*` names, #452); those Bots stay findable in Search when not also `bot.hidden`. ### Approval wait badge on chat rows @@ -100,7 +100,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second ### Bot tree chevron -- The sidebar Bot-list tree (`sidebar.js` `TreeNode`, from slot `flybot.sidebar.structure`) draws collapse/expand as `.bt-tree-toggle`: a 28×28 hit target with a 16px `.bt-tree-chev` glyph, hover background, and a `:focus-visible` accent ring. The glyph rotates when `aria-expanded` is false; the button stays upright so hover and focus stay square. Expand/Collapse `{name}` labels and `aria-expanded` are unchanged. Leaf rows use the same 28px width as a spacer (`.bt-tree-leaf`) so names stay aligned; indent is still `depth * 16px` and the Bot row keeps its height. +- The sidebar Bot-list tree (`sidebar.js` `TreeNode`, from slot `flybot.sidebar.structure`; optional `include(botId)` filters the default list, #452) draws collapse/expand as `.bt-tree-toggle`: a 28×28 hit target with a 16px `.bt-tree-chev` glyph, hover background, and a `:focus-visible` accent ring. The glyph rotates when `aria-expanded` is false; the button stays upright so hover and focus stay square. Expand/Collapse `{name}` labels and `aria-expanded` are unchanged. Leaf rows use the same 28px width as a spacer (`.bt-tree-leaf`) so names stay aligned; indent is still `depth * 16px` and the Bot row keeps its height. ### New-chat menu diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index a753b1086..bf5837834 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -33,11 +33,11 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots | File | Role | | --- | --- | -| `core/src/host/hooks.js` | Host hook points: send middleware, transports, grants, the owner-bound held-send approvals (#52), and task hold/decide/delete seams (#54). | +| `core/src/host/hooks.js` | Host hook points: send middleware, transports, grants, the owner-bound held-send approvals (#52), task hold/decide/delete seams (#54), and `bot.update({ name?, hidden? })` for plugin rename/hide (#452). | | `core/src/host/flybot-feed.js` | Activity feed: folds a Bot Session's `session/event`s into `flybot.on('turn.start' \| 'turn.end' \| 'tool.start' \| 'tool.end' \| 'exchange.delivered')` (#241). | -| `core/src/client/flybot-slots.js` | Client slots, including the approval card queue (`flybot.approvals`, `flybot/decide`). | +| `core/src/client/flybot-slots.js` | Client slots, including the approval card queue (`flybot.approvals`, `flybot/decide`) and `flybot.sidebar.structure` (`parentOf`, optional `include`, `menuItems`, `nodeActions`). | | `core/src/client/flybot-registry.js` | Registry. | -| `core/types/flybot.d.ts` | Types. | +| `core/types/flybot.d.ts` | Types (`BotView.hidden`, `bot.update`, …). | ## Entry points diff --git a/docs/subsystems/tree.md b/docs/subsystems/tree.md index 92cbb7c76..4b2eac541 100644 --- a/docs/subsystems/tree.md +++ b/docs/subsystems/tree.md @@ -52,7 +52,8 @@ The tree organizes itself by role instead of leaving Bots outside it or a parent - **Leads.** A suitable existing lead is a member already marked as that group's lead, already coordinating that group (it has children and the key, project and domain only), or named for it ("Life", "FlyBot Lead") or as a lead ("Kitchen Manager", 负责人). It gets `lead { key, label }`. Only when none fits a new lead is created with a clear role (`