From ef3847414d838e32166a28203a0185b483dbe2d8 Mon Sep 17 00:00:00 2001 From: Dev-2 Date: Mon, 2 Nov 2026 19:07:53 +0000 Subject: [PATCH] tree: keep the reorganization notice, drop its write buttons in a read-only window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notice is a read (`tree/reorg`), so a read-only window keeps the card — what moved, when, the `Kept:` line — but `tree/undo` and `tree/dismiss` are writes the host refuses there, so the card drew two dead controls (#2220). `ReorgCard` now takes `locked` and draws no action row while it is set; the section reads `roster.readOnly` (trimmed) the way the workflow page does. A writable roster renders exactly what it rendered before. Written-by: Dev-2 --- changelog.d/2220-tree-readonly-reorg.fixed.md | 1 + docs/subsystems/tree.md | 2 +- plugins/tree/README.md | 2 +- plugins/tree/client.js | 14 +- plugins/tree/test/reorg-readonly.test.js | 185 ++++++++++++++++++ 5 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 changelog.d/2220-tree-readonly-reorg.fixed.md create mode 100644 plugins/tree/test/reorg-readonly.test.js diff --git a/changelog.d/2220-tree-readonly-reorg.fixed.md b/changelog.d/2220-tree-readonly-reorg.fixed.md new file mode 100644 index 000000000..d789333b1 --- /dev/null +++ b/changelog.d/2220-tree-readonly-reorg.fixed.md @@ -0,0 +1 @@ +The reorganization notice in the sidebar no longer offers `Undo` and `Dismiss` in a read-only window: the card still shows what the last run changed and what an undo kept, but the two buttons are writes the host refuses, so they are not drawn while `roster.readOnly` is set ([#2220](https://github.com/ThinkFlowLab/FlyBot/issues/2220)). diff --git a/docs/subsystems/tree.md b/docs/subsystems/tree.md index 9bd2f14f8..993246039 100644 --- a/docs/subsystems/tree.md +++ b/docs/subsystems/tree.md @@ -79,7 +79,7 @@ The tree organizes itself by role instead of leaving Bots outside it or a parent - **Archive, never delete**: an archived Bot leaves the tree (`archived { at, reason, parent, treeRole, into? }`, tree fields and block removed) but keeps its chat, memory and grants. It is **hidden from the default Bot list** (`bot.hidden` / structure `include`; Search still finds it) (#452), and the sidebar's `flybot-tree-archived` section is the way back to its chat (#810): collapsed by default, header `Archived (N)`, drawn only while N > 0, one row per archived Bot with the same tag `statusOf()` gives (`archived`, `archived · merged into `) opening the chat through `actions.openSession`. `include()` still returns false, so the list and `botTree()` never see it. Deleting it stays the owner's own click. That section is registered at `order: 88` and the reorganization notice below at `order: 89`; a slot renders ascending, so the archived Bots sit **above** the notice. On a merge (#450 item 1), the archived Bot's own memory is carried into the kept Bot through `flybot.memory` (at most 20 entries, tagged `source: merged`), so a merge loses nothing; without the host's memory seam the merge still stands. - **Retire from the core (#638):** the Main Bot's `archive_bot` writes `fields['flybot-tree'].retired = true` (never `archived`, which is the tidy's own record) and sets `bot.hidden`, so the Bot leaves the tree and the default list the way an archived one does while its history stays readable; adoption skips a retired record and `forestOf` no longer counts it, which frees its `maxChildren` slot. The tree panel (`treeOf`) skips a retired Bot the same way (#1793), and the `flybot-tree-archived` section lists it with the `retired` tag so its chat stays reachable; a record that also carries `archived` keeps the archived tag. - **Move, never rename-to-归档 + recreate** (#452): grouping always reparents (`parent`); it never renames a Bot to `归档-…` / `Archive-…` and never creates a replacement with the old name. A one-shot repair strips those prefixes when the bare name is free, or archives+hides the prefixed Bot when a live Bot already has that name. -- **Journal and undo**: every move, new lead, lead mark, merge, archive and idle mark of a run is an action in the root's `reorg { runs (last 5), dismissed, dailyAt }` and a `flybot-tree: reorganized (): …` log line. The tree view's notice (sidebar section `flybot-tree-reorg`, `reorgNotice: false` hides it) shows the last run with **Undo** and **Dismiss** (`tree/reorg`, `tree/undo`, `tree/dismiss`). Undo reverses the newest run: moves go back where the old parent has room and the Bot was not moved again, archived Bots come back, a lead the run created is archived once empty; what it cannot restore is listed as kept. Restored Bots are pinned for `tidy.pinDays` (14) so the daily tidy leaves them alone. +- **Journal and undo**: every move, new lead, lead mark, merge, archive and idle mark of a run is an action in the root's `reorg { runs (last 5), dismissed, dailyAt }` and a `flybot-tree: reorganized (): …` log line. The tree view's notice (sidebar section `flybot-tree-reorg`, `reorgNotice: false` hides it) shows the last run with **Undo** and **Dismiss** (`tree/reorg`, `tree/undo`, `tree/dismiss`); a read-only window (`roster.readOnly`, trimmed) keeps the card and everything it reports but draws neither button, because both are writes the host refuses (#2220). Undo reverses the newest run: moves go back where the old parent has room and the Bot was not moved again, archived Bots come back, a lead the run created is archived once empty; what it cannot restore is listed as kept. Restored Bots are pinned for `tidy.pinDays` (14) so the daily tidy leaves them alone. - **The owner is told afterwards**, no plan card first: the notice in the tree view, and the Main Bot's prompt names the last run (within 7 days, not undone) so Chief can say what moved. - **No silent pull-up**: freeing a slot moves nobody up; the next new Bot takes it. Activity is metadata only (when, which tool names), never message text. diff --git a/plugins/tree/README.md b/plugins/tree/README.md index 7caf6bcea..cca181c6c 100644 --- a/plugins/tree/README.md +++ b/plugins/tree/README.md @@ -15,7 +15,7 @@ Recursive bot tree plugin for FlyBot (built on ds-bot / DeepSeek Harness). - **`create_child_bot` is serialized per parent:** parallel calls never exceed `maxChildren` or create duplicate-name siblings. Two calls that are **in flight at the same time** and belong to the same project or domain become one group instead of each landing in whatever broader group it fits: one lead for that key (reused when it exists, created once otherwise) with both new Bots under it (#810). The overlap is the definition: one turn of the event loop, no timer, and a call that starts after the previous one returned keeps the ordinary placement. - Delegation is level by level: coordinators delegate to direct children whose scope fits and aggregate upward; a bot with no children does the work itself. A one-line trace like `Chief → Work → Dev` is added only to answers a tree bot sends to its parent tree bot; replies to the user or to a remote (friend's, Bot Social) bot never carry it, and the root (Chief) never adds one. - Tools (visible to every tree bot, not only Main/root — #16 Option B): `list_children()`, `find_bots(query?)`, `create_child_bot(name, role_description, parent?)`, `create_team(team, members, parent?)` and — root only — `move_bot(bot, parent)` (#635), which moves an existing Bot with its subtree under another parent after checking for cycles, `maxChildren`, `maxDepth` and duplicate sibling names, then refreshes every prompt whose trace changed. Unlike `create_child_bot` it never creates a Bot; use it to reorganise a team, and note that deleting a Bot still re-attaches its children to its parent. A bot creates only in its own downstream: `parent` (id or name) must be the caller or one of its descendants, and limits are checked at that parent. **Forward allocation (#447, `src/allocate.js`):** `find_bots` (read-only) lists the bots in the caller's branch (Chief: the whole tree) with role, parent, depth and path, matches first; `create_child_bot` reuses a bot with the same role anywhere in the caller's branch instead of creating a duplicate (`{ reused, note }`), and without `parent` puts the new bot under a marked group lead in the branch that its role belongs to ("FlyBot Docs" under "FlyBot Lead"), else under the caller; `create_team` makes a project team as a lead plus children (`Chief → FlyBot Lead → {Dev, Test, …}`, `members` as `"Name: role"` strings), one slot under the parent, reusing an existing lead and same-role members (a flat one moves under the lead, journaled and undoable). Allocation uses the self-organization's primitives (role profiles, leads, moves, journal); a concurrent burst of two or more `create_child_bot` calls in one group is placed as one leader plus members (#810), a call that only reuses an existing Bot is not counted as a member of that group (#819), two seats with the same name and role are one member, named once in the lead's role text (#840), and an explicit `parent` keeps the caller's own placement; a full level, orphans (a bot whose parent is gone is reattached by role on the next repair pass) and duplicates are left to the self-organization, which never asks first. There is no plan card. Limits: depth 5 (root = 1), 8 children per node; duplicate sibling names (case/space-insensitive) are rejected; names are 1–40 characters (ds-bot's roster limit). Every new child gets the same recursive system prompt template (`src/prompt.js`). A new child starts with no person-grant even if its parent is granted; sharing it still needs an independent Sharing → Copy invite link (default deny). -- Frontend (`client.js`, bundle id `@flybot/tree`, injects `flybot`): the tree is drawn by ds-bot's own Bot list, so this plugin's separate "Tree" sidebar section is **off by default** (`sidebarSection: true` in the client config brings it back). It always exposes the synchronous helper `ctx.flybot.tree.forest(bots)` (also `globalThis.__flybot__.tree.forest(bots)`), see **Tree data** below. It also registers `flybot.sidebar.structure` (`{ parentOf(botId) }`, parents from `forestOf` over `ctx.flybot.bots()`, refreshed on field changes and every 10 s); ds-bot's sidebar uses it to draw its Bot list as one expandable tree with Chief on top. Retired, archived and outside Bots and other Main Bots have no parent there, so ds-bot shows them directly under Chief; the entry's `nodeActions` draws a tag after such a row ("archived", "archived · merged into …", "outside the tree") and after an idle Bot ("idle"), so the sidebar and the tree model agree. A `flybot.sidebar.section` entry `flybot-tree-archived` lists the archived Bots the Bot list hides (#810): collapsed by default, header `Archived (N)`, nothing at all while N is 0, one row per Bot carrying the same tag (`archived`, or `archived · merged into …` after a merge) that opens that Bot's chat through `actions.openSession`; expanding is local state, so the 10 s refresh re-renders the rows without closing it (`archivedSection: false` hides it). It is registered with `order: 88` and the reorganization notice with `order: 89`: a slot renders in ascending order, so the archived list sits **above** the notice (a smaller number is higher) instead of the card pushing it down. The structure entry and the archived list read `ctx.flybot.bots()` through **one** shared feed (`rosterFeed`, `{ interval }`): the first subscriber starts the poll, later ones join it without a read of their own, a field change or a removal asks for an immediate read (`refresh()`), and a mounted sidebar asks the host once per 10 s tick — not once per consumer (#816). A `flybot.sidebar.section` entry `flybot-tree-reorg` shows the last reorganization (what moved) with Undo and Dismiss, through the host endpoints `tree/reorg`, `tree/undo`, `tree/dismiss`; it draws nothing when there is none in the last 7 days (`reorgNotice: false` in the client config hides it). The entry also has `menuItems({ bot })`, which ds-bot adds to each Bot's right-click menu: one item, `{ id: 'copy-invite', label: 'Copy invite link' }` (with `visible`, `run`); the invite with instructions is offered in the Bot's Sharing section only, so the menu does not list the same invite twice (#173). `visible` is `social.canInvite(bot.id) === true` (false without the social plugin); `run` calls `social.createInvite(botId)` (or `flybot.call('social.createInvite', { botId })`). **Copy invite link** copies only the bare URL (`link`); `copyInvite(flybot, botId, 'text')` copies the full message `text` for other callers; a missing field is an error, never swapped for the other. The clipboard write starts inside the click via `ClipboardItem` with the text as a promise, so it keeps the click's permission while the invite is created; `writeText` then a textarea are the fallbacks. It returns "Invite link for copied", which ds-bot shows as a toast; a failure throws and shows as the error toast. Right-clicking never opens the Bot's chat. +- Frontend (`client.js`, bundle id `@flybot/tree`, injects `flybot`): the tree is drawn by ds-bot's own Bot list, so this plugin's separate "Tree" sidebar section is **off by default** (`sidebarSection: true` in the client config brings it back). It always exposes the synchronous helper `ctx.flybot.tree.forest(bots)` (also `globalThis.__flybot__.tree.forest(bots)`), see **Tree data** below. It also registers `flybot.sidebar.structure` (`{ parentOf(botId) }`, parents from `forestOf` over `ctx.flybot.bots()`, refreshed on field changes and every 10 s); ds-bot's sidebar uses it to draw its Bot list as one expandable tree with Chief on top. Retired, archived and outside Bots and other Main Bots have no parent there, so ds-bot shows them directly under Chief; the entry's `nodeActions` draws a tag after such a row ("archived", "archived · merged into …", "outside the tree") and after an idle Bot ("idle"), so the sidebar and the tree model agree. A `flybot.sidebar.section` entry `flybot-tree-archived` lists the archived Bots the Bot list hides (#810): collapsed by default, header `Archived (N)`, nothing at all while N is 0, one row per Bot carrying the same tag (`archived`, or `archived · merged into …` after a merge) that opens that Bot's chat through `actions.openSession`; expanding is local state, so the 10 s refresh re-renders the rows without closing it (`archivedSection: false` hides it). It is registered with `order: 88` and the reorganization notice with `order: 89`: a slot renders in ascending order, so the archived list sits **above** the notice (a smaller number is higher) instead of the card pushing it down. The structure entry and the archived list read `ctx.flybot.bots()` through **one** shared feed (`rosterFeed`, `{ interval }`): the first subscriber starts the poll, later ones join it without a read of their own, a field change or a removal asks for an immediate read (`refresh()`), and a mounted sidebar asks the host once per 10 s tick — not once per consumer (#816). A `flybot.sidebar.section` entry `flybot-tree-reorg` shows the last reorganization (what moved) with Undo and Dismiss, through the host endpoints `tree/reorg`, `tree/undo`, `tree/dismiss`; in a read-only window (`roster.readOnly`, trimmed) the card still shows what moved but draws no Undo and no Dismiss, because those two are writes the host refuses (#2220); it draws nothing when there is none in the last 7 days (`reorgNotice: false` in the client config hides it). The entry also has `menuItems({ bot })`, which ds-bot adds to each Bot's right-click menu: one item, `{ id: 'copy-invite', label: 'Copy invite link' }` (with `visible`, `run`); the invite with instructions is offered in the Bot's Sharing section only, so the menu does not list the same invite twice (#173). `visible` is `social.canInvite(bot.id) === true` (false without the social plugin); `run` calls `social.createInvite(botId)` (or `flybot.call('social.createInvite', { botId })`). **Copy invite link** copies only the bare URL (`link`); `copyInvite(flybot, botId, 'text')` copies the full message `text` for other callers; a missing field is an error, never swapped for the other. The clipboard write starts inside the click via `ClipboardItem` with the text as a promise, so it keeps the click's permission while the invite is created; `writeText` then a textarea are the fallbacks. It returns "Invite link for copied", which ds-bot shows as a toast; a failure throws and shows as the error toast. Right-clicking never opens the Bot's chat. ### Config ```yaml diff --git a/plugins/tree/client.js b/plugins/tree/client.js index c978e6720..eb0f97857 100644 --- a/plugins/tree/client.js +++ b/plugins/tree/client.js @@ -427,8 +427,8 @@ window.__ModuleLoader__.load({ if (!run || overview.dismissed === run.id || now - run.at > NOTICE_DAYS * 24 * 60 * 60 * 1000) return null return run } - /** The notice body (pure, for tests): title, what moved, Undo / Dismiss. */ - function ReorgCard({ run, busy = false, note = null, onUndo, onDismiss }) { + /** The notice body (pure, for tests): title, what moved, Undo / Dismiss (neither while locked). */ + function ReorgCard({ run, busy = false, note = null, locked = false, onUndo, onDismiss }) { const lines = run.summary ?? [] const undone = Boolean(run.undone) return h('div', { className: 'flybot-reorg', role: 'status', 'aria-label': 'Tree reorganized' }, @@ -436,17 +436,21 @@ window.__ModuleLoader__.load({ h('ul', { className: 'flybot-reorg-list' }, ...lines.slice(0, NOTICE_LINES).map((line, i) => h('li', { key: i }, line)), lines.length > NOTICE_LINES ? h('li', { key: 'more' }, `and ${lines.length - NOTICE_LINES} more`) : null), undone && run.undone.kept?.length ? h('div', { className: 'flybot-reorg-note' }, `Kept: ${run.undone.kept.join('; ')}`) : null, - h('div', { className: 'flybot-reorg-actions' }, + locked ? null : h('div', { className: 'flybot-reorg-actions' }, undone ? null : h('button', { type: 'button', className: 'flybot-reorg-btn', disabled: busy, onClick: onUndo }, 'Undo'), h('button', { type: 'button', className: 'flybot-reorg-btn', disabled: busy, onClick: onDismiss }, 'Dismiss')), note ? h('div', { className: 'flybot-reorg-note', role: 'alert' }, note) : null) } function makeReorgNotice(flybot) { - return function ReorgNotice() { + return function ReorgNotice({ roster }) { useReorgStyles() const [overview, setOverview] = useState(null) const [busy, setBusy] = useState(false) const [note, setNote] = useState(null) + // The notice is a read (`tree/reorg`), so a read-only window keeps it; `tree/undo` and + // `tree/dismiss` are writes the host refuses there, so it offers neither (#2220). Same + // trimmed signal the workflow page uses for its own write entries (#2200). + const locked = (typeof roster?.readOnly === 'string' ? roster.readOnly.trim() : '') !== '' const load = useCallback(() => { Promise.resolve(flybot.call('tree/reorg', {})).then(v => setOverview(v ?? null), () => { /* an older host has no endpoint: no notice */ }) }, []) useEffect(() => { load() @@ -466,7 +470,7 @@ window.__ModuleLoader__.load({ setBusy(false) load() } - return h(ReorgCard, { run, busy, note, onUndo: act('tree/undo'), onDismiss: act('tree/dismiss') }) + return h(ReorgCard, { run, busy, note, locked, onUndo: act('tree/undo'), onDismiss: act('tree/dismiss') }) } } // The tag after a Bot's row: 'idle' (no activity for a long time) or 'archived' (out of the tree; diff --git a/plugins/tree/test/reorg-readonly.test.js b/plugins/tree/test/reorg-readonly.test.js new file mode 100644 index 000000000..b2f41d4cc --- /dev/null +++ b/plugins/tree/test/reorg-readonly.test.js @@ -0,0 +1,185 @@ +// #2220: the reorganization notice is a read (`tree/reorg`), so a read-only window keeps the card — +// what moved, when, `Kept:` — but `tree/undo` and `tree/dismiss` are writes the host refuses, so the +// card must not offer them there. `roster.readOnly`, trimmed, is the same signal the workflow page +// uses (#2200). A writable roster draws the card byte for byte as before. +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' + +// A hook renderer: unlike ./render.js it keeps state and runs effects, so the notice's `load()` +// lands and `__flybot__`-style handlers can be pressed. `useCallback` is memoised by deps — the +// client's effect depends on `load`, and a fresh callback per render would re-run it forever. +const live = { inst: null, idx: 0 } +const react = { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + useState(init) { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { value: typeof init === 'function' ? init() : init } + const slot = inst.hooks[i] + return [slot.value, (next) => { + const value = typeof next === 'function' ? next(slot.value) : next + if (!Object.is(value, slot.value)) { slot.value = value; inst.root.schedule() } + }] + }, + useEffect(fn, deps) { + const { inst } = live + const i = live.idx++ + const prev = inst.hooks[i] + const changed = !prev || !deps || !prev.deps || deps.length !== prev.deps.length || deps.some((dep, k) => !Object.is(dep, prev.deps[k])) + if (!prev) inst.hooks[i] = { deps } + if (changed) { inst.hooks[i].deps = deps; inst.root.pending.push({ slot: inst.hooks[i], fn }) } + }, + useCallback(fn, deps) { + const { inst } = live + const i = live.idx++ + const prev = inst.hooks[i] + const changed = !prev || !deps || !prev.deps || deps.length !== prev.deps.length || deps.some((dep, k) => !Object.is(dep, prev.deps[k])) + if (changed) inst.hooks[i] = { fn, deps } + return inst.hooks[i].fn + }, +} + +function mount(Component, props = {}) { + const root = { instances: new Map(), pending: [], rendering: false, dirty: false, tree: undefined } + const expand = (node, path) => { + if (node === null || node === undefined || typeof node === 'boolean') return undefined + if (typeof node !== 'object') return String(node) + if (Array.isArray(node)) return node.map((child, i) => expand(child, `${path}.${i}`)) + if (typeof node.type === 'function') { + let inst = root.instances.get(path) + if (!inst) { inst = { hooks: [], root }; root.instances.set(path, inst) } + inst.used = true + live.inst = inst + live.idx = 0 + return expand(node.type({ ...node.props, children: node.children }), `${path}/c`) + } + return { type: node.type, props: node.props, children: node.children.map((child, i) => expand(child, `${path}.${i}`)).flat(Infinity).filter(child => child !== undefined) } + } + const render = () => { + root.rendering = true + for (const inst of root.instances.values()) inst.used = false + root.tree = expand({ type: Component, props, children: [] }, 'r') + const stale = [] + for (const [path, inst] of root.instances) if (!inst.used) stale.push(path) + for (const path of stale) { + for (const slot of root.instances.get(path).hooks) slot?.cleanup?.() + root.instances.delete(path) + } + const run = root.pending.splice(0) + for (const { slot, fn } of run) { slot.cleanup?.(); const out = fn(); slot.cleanup = typeof out === 'function' ? out : undefined } + root.rendering = false + if (root.dirty || root.pending.length) { root.dirty = false; render() } + } + root.schedule = () => { if (root.rendering) root.dirty = true; else render() } + render() + return { + get tree() { return root.tree }, + rerender(nextProps = {}) { Object.assign(props, nextProps); root.schedule() }, + unmount() { for (const inst of root.instances.values()) for (const slot of inst.hooks) slot?.cleanup?.(); root.instances.clear() }, + } +} + +const findAll = (node, pred, out = []) => { + if (Array.isArray(node)) { for (const child of node) findAll(child, pred, out); return out } + if (!node || typeof node !== 'object') return out + if (pred(node)) out.push(node) + for (const child of node.children ?? []) findAll(child, pred, out) + return out +} +const textOf = node => (typeof node === 'string' ? node : node ? (node.children ?? []).map(textOf).join('') : '') +const buttonsOf = tree => findAll(tree, node => node.type === 'button' && node.props?.className === 'flybot-reorg-btn') +const labelOf = node => textOf(node) +const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); await new Promise(resolve => setTimeout(resolve, 0)) } + +const loadClient = () => { + let mod + globalThis.window = { __ModuleLoader__: { load: ({ factory }) => { mod = factory(() => react) } } } + new Function(readFileSync(new URL('../client.js', import.meta.url), 'utf8'))() + return mod +} + +const READ_ONLY = 'This window shows the team but cannot change it.' +const run = { id: 'run-1', at: Date.now() - 60_000, trigger: 'full', summary: ['Moved 3 Bots under FlyBot Lead'], actions: [] } + +/** A fake `ctx.flybot` that answers `tree/reorg` and records every call. */ +function fakeFlybot(overview) { + const calls = [] + return { + calls, + call(name, body) { + calls.push({ name, body }) + if (name === 'tree/reorg') return Promise.resolve(overview) + return Promise.resolve({ ok: true }) + }, + fields: { on: () => () => {} }, + } +} + +const notice = (mod, overview, roster) => { + const flybot = fakeFlybot(overview) + const Notice = mod.makeReorgNotice(flybot) + const root = mount(Notice, { roster, actions: {}, call: flybot.call }) + return { flybot, root } +} + +test('#2220: a read-only window keeps the notice but offers no Undo and no Dismiss', async () => { + const mod = loadClient() + const { flybot, root } = notice(mod, { runs: [run], dismissed: null }, { bots: [], readOnly: READ_ONLY }) + try { + await flush() + const buttons = buttonsOf(root.tree) + assert.equal(buttons.length, 0, `a read-only notice draws no write entry, got ${buttons.map(labelOf).join(', ')}`) + assert.equal(findAll(root.tree, n => n.props?.className === 'flybot-reorg').length, 1, 'the card itself stays') + const text = textOf(root.tree) + assert.match(text, /Tree reorganized/) + assert.match(text, /Moved 3 Bots under FlyBot Lead/, 'what moved is still readable') + assert.deepEqual(flybot.calls.filter(c => c.name === 'tree/undo' || c.name === 'tree/dismiss'), [], 'no write is even attempted') + assert.equal(flybot.calls.filter(c => c.name === 'tree/reorg').length >= 1, true, 'the read still happened') + } finally { root.unmount() } +}) + +test('#2220: a read-only window still shows what Undo kept', async () => { + const mod = loadClient() + const undone = { ...run, undone: { at: run.at + 1, restored: [], kept: ['FlyBot Dev: moved again since'] } } + const { root } = notice(mod, { runs: [undone], dismissed: null }, { bots: [], readOnly: READ_ONLY }) + try { + await flush() + assert.equal(buttonsOf(root.tree).length, 0) + const text = textOf(root.tree) + assert.match(text, /Tree reorganization undone/) + assert.match(text, /Kept: FlyBot Dev: moved again since/) + } finally { root.unmount() } +}) + +test('#2220: a writable roster keeps both buttons, and Undo issues tree/undo', async () => { + const mod = loadClient() + for (const roster of [{ bots: [] }, { bots: [], readOnly: '' }, { bots: [], readOnly: ' ' }]) { + const { flybot, root } = notice(mod, { runs: [run], dismissed: null }, roster) + try { + await flush() + const buttons = buttonsOf(root.tree) + assert.deepEqual(buttons.map(labelOf), ['Undo', 'Dismiss'], `roster ${JSON.stringify(roster.readOnly)} stays writable`) + buttons[0].props.onClick() + await flush() + assert.deepEqual(flybot.calls.filter(c => c.name === 'tree/undo').map(c => c.body), [{ runId: 'run-1' }]) + } finally { root.unmount() } + } +}) + +test('#2220: no fresh run draws no card, read-only or writable', async () => { + const mod = loadClient() + const stale = { runs: [{ ...run, at: Date.now() - 8 * 24 * 60 * 60 * 1000 }], dismissed: null } + const dismissed = { runs: [run], dismissed: 'run-1' } + const empty = { runs: [] } + for (const overview of [stale, dismissed, empty]) { + for (const roster of [{ bots: [] }, { bots: [], readOnly: READ_ONLY }]) { + const { root } = notice(mod, overview, roster) + try { + await flush() + assert.equal(findAll(root.tree ?? null, n => n.props?.className === 'flybot-reorg').length, 0) + assert.equal(buttonsOf(root.tree ?? null).length, 0) + } finally { root.unmount() } + } + } +})