From 3f600a3d6bee59a7296c2ca744e76dcbf863212b Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Mon, 19 Oct 2026 22:47:26 +0800 Subject: [PATCH 1/2] =?UTF-8?q?Tree=20reorg=20MOVEs=20bots;=20never=20rena?= =?UTF-8?q?me=20to=20=E5=BD=92=E6=A1=A3-=20and=20recreate=20(#452)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #452 Owner decision 2026-10-19 22:23 Asia/Shanghai: grouping reparents existing Bots; never renames old ones to 归档- / Archive- and creates replacements. Archived Bots leave the default Bot list; leftover 归档-* names are repaired. - organize.js: archive sets bot.hidden; undo clears it; repairArchiveNames strips 归档-/Archive- when the bare name is free, else archives+hides the prefixed duplicate; reorg never renames for grouping - flybot.bot.update({ name, hidden }) for plugin rename/hide - sidebar structure.include hides archived and 归档-* from the default list - tests for move-only reorg, hide-on-archive, repair, and include() Risk tier: T2 (plugins/tree + core host/client; no create_child_bot topic) --- .../452-tree-reorg-move-not-rename.fixed.md | 1 + core/client.js | 5 +- core/src/client/flybot-slots.js | 3 +- core/src/client/sidebar.js | 6 +- core/src/host/hooks.js | 14 ++++ core/test/new-chat.test.js | 5 +- core/types/flybot.d.ts | 4 ++ docs/subsystems/tree.md | 3 +- patches/ds-bot/0003-flybot-api-client.patch | 8 ++- patches/ds-bot/0008-client-bundle.patch | 7 +- .../ds-bot/files/src/client/flybot-slots.js | 3 +- patches/ds-bot/files/src/host/hooks.js | 14 ++++ patches/ds-bot/files/test/new-chat.test.js | 5 +- patches/ds-bot/files/types/flybot.d.ts | 4 ++ plugins/tree/client.js | 21 ++++-- plugins/tree/src/adapter.js | 5 ++ plugins/tree/src/organize.js | 66 ++++++++++++++++++- plugins/tree/src/tree.js | 6 +- plugins/tree/test/mock-flybot.js | 18 ++++- plugins/tree/test/tree.test.js | 61 +++++++++++++++++ 20 files changed, 232 insertions(+), 27 deletions(-) create mode 100644 changelog.d/452-tree-reorg-move-not-rename.fixed.md 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/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 (`