From 11c3c80103f2e7183dd10abc5a419bb02769d5d6 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:58:12 +0800 Subject: [PATCH] fix(client): re-read the board when the rail switches Bots The board's load effect was keyed by the scope kind alone, so every Bot scope shared the key `own` and switching the rail kept the first Bot's archive page and its poll. The Done column of the newly selected Bot then filtered that page by its own id, so it read empty until the dialog was reopened, while the heading still said the page was that Bot's history. The key now names the Bot, so a switch re-reads the new Bot's archive. --- changelog.d/2070-kanban-rail-switch.fixed.md | 1 + core/client.js | 2 +- core/src/client/kanban.js | 4 +- core/test/kanban.test.js | 162 +++++++++++++++++++ docs/subsystems/core-client.md | 2 +- patches/ds-bot/0008-client-bundle.patch | 2 +- patches/ds-bot/files/src/client/kanban.js | 4 +- patches/ds-bot/files/test/kanban.test.js | 162 +++++++++++++++++++ 8 files changed, 334 insertions(+), 5 deletions(-) create mode 100644 changelog.d/2070-kanban-rail-switch.fixed.md diff --git a/changelog.d/2070-kanban-rail-switch.fixed.md b/changelog.d/2070-kanban-rail-switch.fixed.md new file mode 100644 index 000000000..d55acac98 --- /dev/null +++ b/changelog.d/2070-kanban-rail-switch.fixed.md @@ -0,0 +1 @@ +The task board's Done column now follows the rail: switching from one Bot to another re-reads that Bot's archive page instead of keeping the one the board loaded for the previous Bot, which had left the newly selected Bot's Done column empty until the dialog was reopened ([#2070](https://github.com/ThinkFlowLab/FlyBot/issues/2070)). diff --git a/core/client.js b/core/client.js index a082078a6..c481cec0a 100644 --- a/core/client.js +++ b/core/client.js @@ -8862,7 +8862,7 @@ function useBoardTasks(actions, scope) { const [own, setOwn] = (0, import_react15.useState)([]); const [groupTasks, setGroupTasks] = (0, import_react15.useState)([]); const [archive, setArchive] = (0, import_react15.useState)([]); - const key = scope?.kind === "group" ? `group:${scope.roomId}` : "own"; + const key = scope?.kind === "group" ? `group:${scope.roomId}` : `bot:${scope.botId ?? ""}`; (0, import_react15.useEffect)(() => { let stopped = false; const load = () => { diff --git a/core/src/client/kanban.js b/core/src/client/kanban.js index 65066cd34..c1c8c4857 100644 --- a/core/src/client/kanban.js +++ b/core/src/client/kanban.js @@ -167,7 +167,9 @@ export function useBoardTasks(actions, scope) { const [own, setOwn] = useState([]) const [groupTasks, setGroupTasks] = useState([]) const [archive, setArchive] = useState([]) - const key = scope?.kind === 'group' ? `group:${scope.roomId}` : 'own' + // P-360: a Bot board's archive page names that Bot, so the key has to name it too — a shared + // `'own'` kept the first Bot's page (and its poll) after the rail switched to another Bot. + const key = scope?.kind === 'group' ? `group:${scope.roomId}` : `bot:${scope.botId ?? ''}` useEffect(() => { let stopped = false const load = () => { diff --git a/core/test/kanban.test.js b/core/test/kanban.test.js index e0bc63dc9..c8b5ffc7b 100644 --- a/core/test/kanban.test.js +++ b/core/test/kanban.test.js @@ -430,3 +430,165 @@ test('P-364: a room board rail counts the room\'s active cards, not only its arc assert.equal(room.railMeta('s'), '1/4', 'four drawn cards, one of them finished: {done}/{total}, not {done}/{done}') assert.equal(room.railMeta('a'), '0/1', 'a Bot with an active card and none finished is 0 of 1, not a blank rail') }) + +// P-360: the rail switch from one Bot to another. The fake React at the top of this file hands an +// effect back to the test and never re-runs one, so a switch case written against it passes whatever +// the hook does (#1810). This is a second, stateful context: hook slots per component, an effect +// re-run only when its deps change, a setter that re-renders, and the board's poll on a fake clock. +function mountBoard(actions) { + const h = (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }) + const runtime = { instances: new Map(), key: 'root', index: 0, dirty: false, timer: 0, timers: new Map() } + const instance = (key) => { + let found = runtime.instances.get(key) + if (!found) { + found = { slots: [], refs: [], pending: [], deps: new Map(), cleanups: new Map() } + runtime.instances.set(key, found) + } + return found + } + const scoped = (key, run) => { + const previousKey = runtime.key + const previousIndex = runtime.index + runtime.key = key + runtime.index = 0 + try { return run() } finally { runtime.key = previousKey; runtime.index = previousIndex } + } + const react = { + createElement: h, Fragment: Symbol('fragment'), + useState: (initial) => { + const slot = instance(runtime.key) + const index = runtime.index++ + if (!(index in slot.slots)) slot.slots[index] = typeof initial === 'function' ? initial() : initial + const set = (next) => { + const value = typeof next === 'function' ? next(slot.slots[index]) : next + if (Object.is(value, slot.slots[index])) return + slot.slots[index] = value + runtime.dirty = true + } + return [slot.slots[index], set] + }, + useEffect: (run, deps) => { instance(runtime.key).pending.push({ index: runtime.index++, run, deps }) }, + useLayoutEffect: (run, deps) => { instance(runtime.key).pending.push({ index: runtime.index++, run, deps }) }, + useMemo: fn => fn(), useCallback: fn => fn(), + useRef: (current) => { + const slot = instance(runtime.key) + const index = runtime.index++ + if (!(index in slot.refs)) slot.refs[index] = { current } + return slot.refs[index] + }, + } + const commit = () => { + for (const slot of runtime.instances.values()) { + const pending = slot.pending + slot.pending = [] + for (const { index, run, deps } of pending) { + const previous = slot.deps.get(index) + const changed = !deps || !previous || deps.length !== previous.length || deps.some((dep, at) => !Object.is(dep, previous[at])) + if (!changed) continue + const cleanup = slot.cleanups.get(index) + if (typeof cleanup === 'function') cleanup() + slot.cleanups.delete(index) + slot.deps.set(index, deps ? [...deps] : null) + const next = run() + if (typeof next === 'function') slot.cleanups.set(index, next) + } + } + } + const expandLive = (node, path = 'r') => { + if (node === null || node === undefined) return null + if (Array.isArray(node)) return node.map((child, at) => expandLive(child, `${path}.${at}`)) + if (typeof node !== 'object') return node + if (typeof node.type === 'function') return scoped(path, () => expandLive(node.type({ ...node.props, children: node.children }), path)) + return { ...node, children: (node.children ?? []).map((child, at) => expandLive(child, `${path}.${at}`)) } + } + const env = { + module: { exports: {} }, + setTimeout, clearTimeout, + // The dialog's escape layer and its `#task=` deep link touch the page; neither is what this + // case reads, so they get the smallest window that lets the board mount. + window: { location: { hash: '' }, addEventListener() {}, removeEventListener() {} }, + setInterval: (fn, ms) => { + const id = ++runtime.timer + runtime.timers.set(id, { every: Math.max(1, Number(ms) || 1), fn }) + return id + }, + clearInterval: (id) => runtime.timers.delete(id), + } + env.require = (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: (node) => node } + throw new Error(`no platform module ${name}`) + } + runInNewContext(bundle.outputFiles[0].text, env) + const live = env.module.exports + let tree = null + const draw = () => { + runtime.dirty = false + for (const slot of runtime.instances.values()) slot.pending = [] + tree = expandLive(h(live.KanbanDialog, { roster, actions })) + commit() + return tree + } + const settle = async () => { + for (let guard = 0; guard < 50; guard += 1) { + await new Promise(resolve => setImmediate(resolve)) + if (!runtime.dirty) return tree + draw() + } + throw new Error('the board never settled') + } + const rail = (botId) => { + const button = all(tree).find(node => node.type === 'button' && node.props?.['data-kind'] === 'bot' && node.props?.['data-id'] === botId) + assert.ok(button, `the rail names Bot ${botId}`) + button.props.onClick() + } + const column = (name) => byClass(tree, 'bt-board-column').find(node => node.props['data-column'] === name) + return { draw, settle, rail, column, get tree() { return tree } } +} + +// A Bot board's Done column is that Bot's own archive page. Switching the rail therefore has to +// send a fresh `{ botId, archive: true, limit: 20 }` read: the loaded page is scoped to the Bot it +// was asked for, and the column filters it by the newly selected Bot, so a page kept from the +// previous Bot is filtered down to nothing and the Done column reads empty. +test('P-360: switching the rail re-reads the archive, and the new Bot draws its own Done page', async () => { + const calls = [] + const finished = (botId, count) => Array.from({ length: count }, (_, index) => ({ + id: `task-${botId}-done-${index}`, ownerBotId: botId, title: `${botId.toUpperCase()} finished ${index}`, + status: 'accepted', progress: 100, createdAt: index, updatedAt: 100 + index, + })) + const pages = { a: finished('a', ARCHIVE_SHOWN), s: finished('s', ARCHIVE_SHOWN) } + const actions = { + closeBoard: () => {}, + boardClose: () => {}, + tasks: (query) => { + calls.push({ ...query }) + return Promise.resolve(query?.archive ? pages[query.botId] ?? [] : []) + }, + } + const board = mountBoard(actions) + const archiveReads = () => calls.filter(query => query?.archive).map(query => query.botId) + + board.draw() + await board.settle() + // The board opens on the first Bot of the rail (Chief `a`). + assert.deepEqual(archiveReads(), ['a'], 'the board opens on the first Bot and reads its archive') + assert.equal(byClass(board.column('done'), 'bt-board-card').length, ARCHIVE_SHOWN, 'Chief draws its own finished cards') + assert.match(textOf(board.column('done')), /A finished 19/) + + board.rail('s') + await board.settle() + // The switch has to ask for Writer's page, not keep Chief's: the loaded rows are Chief's, and the + // column filters them by Writer, so a kept page is an empty column. + assert.deepEqual(archiveReads(), ['a', 's'], 'switching to Writer asks for Writer\'s archive page') + assert.deepEqual(calls.filter(query => query?.archive).at(-1), { botId: 's', archive: true, limit: ARCHIVE_SHOWN }, 'the new read names the new Bot') + assert.equal(byClass(board.column('done'), 'bt-board-card').length, ARCHIVE_SHOWN, 'Writer draws its own finished cards, not an empty column') + assert.match(textOf(board.column('done')), /S finished 19/) + assert.doesNotMatch(textOf(board.column('done')), /A finished/) + + // And back again: every switch re-reads, not only the first one. + board.rail('a') + await board.settle() + assert.deepEqual(archiveReads(), ['a', 's', 'a'], 'switching back to Chief reads Chief\'s page again') + assert.equal(byClass(board.column('done'), 'bt-board-card').length, ARCHIVE_SHOWN, 'Chief draws its own finished cards again') + assert.match(textOf(board.column('done')), /A finished 19/) +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 1ac40ceef..ecbd608f4 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -45,7 +45,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/src/shared/preview.js` | What a preview may show of a stored line (#130, #1347): `visibleText(text)` unwraps complete `` wraps and then removes leftover marker fragments until neither changes the text — a `from="User">` tail, a `untrusted-message>` whose brackets an older pass already stripped, a nested fragment that would otherwise rebuild a marker — and folds the result to the one line a sidebar row is. `stripFence` is the marker half on its own. It is used where a row is built (`previewOf` in `src/client/text.js`, the group row in `src/client/sidebar.js`, the host's `preview` in `src/host/channel.js`), so no surface has to remember it, and by the details drawer's copy export: `transcriptOf` (`src/client/text.js`) reads each stored turn through the same helper, so a copied conversation carries the words, not an untrusted fence, and `copyBodyOf` drops a leading delegation trace only when its first hop is a Bot's name taken from the roster (`copyNames`; longest first, and no names strips nothing), which keeps a line like `Settings → Models 页面` as written; a copied reply keeps its own line breaks, and only a trace-only line or a fence-marker line is dropped. Tests: `core/test/ui-clickthrough.test.js`, `core/test/details-copy.test.js`. | | `core/src/client/board-card.js` | The task board's card frame (#1363): `BoardCard` draws the identity line, the title, the note and the actions the board passes in, with `TASK_SKIN`. A card carries `data-source` (`task`) and `data-key` (`task:`). The caller is `kanban.js`. Tests: `core/test/board-card.test.js`, `core/test/kanban.test.js`. | | `core/src/shared/board-card.js` | Task-card identity (#1363): `taskCardKey(id)` is `task:` and `taskCard(task)` builds the model `BoardCard` draws. Tests: `core/test/board-card.test.js`. | -| `core/src/client/kanban.js` | Per-Bot task board (#1058, #1099): the **Tasks** row under Inbox (`KanbanRow`, `ui.board`, `actions.openBoard` / `closeBoard`) and `KanbanDialog`. `boardRail` lists the viewer's Bots nested by `createdBy` and the groups they administer; `boardColumns` maps task status onto **Not started** / **In progress** / **Done**. Cards show id, priority, title, note, assignee and progress, and are drawn by the shared `board-card.js` component: a task card says `source: 'task'`, keys `task:` and keeps `data-task` / `data-column` for this board's own selectors (#1265 step 1). The dialog body is `BoardView` (#1363): the rail stays per-Bot and the columns are the task columns only. The GitHub metrics panel (Today / 7 days, Retry, Diagnose, the package-default repository header) is not mounted. A task card is the shared `BoardCard` with `TASK_SKIN`, and dragging it or dropping it on a column only reports the request to the dialog's `onMove` (`actions.taskUpdate`), so the view still never writes. In a group scope, `columnTree` nests subtasks under the overall task (`kind: 'group'`) when they share a column, and a subtask in another column says which overall task it is part of. That scope also says assigning, claiming, and progress live on the board (overall task, subtask, assignee, column, progress) and that @ only wakes someone. Drag or **Start** / **Complete** calls `actions.taskUpdate`; only the Bot's owner gets those actions. A group scope loads `tasks { roomId }`. Since #1252 the default read is active work only and the Done column merges a second read of the finished rows — `tasks { ...query, archive: true, limit: ARCHIVE_SHOWN }`, which is room-scoped for a group and names the Bot for a Bot board, because the host cuts the page before the board can filter by Bot — newest-settled first by `updatedAt` (tiebreak `createdAt`), finished rows only, and cut to that limit, so a room with hundreds of finished cards still renders a twenty-card column while the poll carries the slice and not the history. Since #2045 that page is also counted for what it is: when the archive holds a full page (`ARCHIVE_SHOWN`), the Done heading reads `20 newest` and a Bot's rail row reads `20+ done` rather than a fraction that would present the page as the history, and in a group the page is cut across the room, so every Bot's share of it is a lower bound and reads the same way rather than presenting its slice as its own total; a page under the limit keeps `{done}/{total}` exactly as before. P-364: the rail adds up the list the scope actually draws from, and a room board keeps its active cards in a second list of its own (`groupTasks`) — `own` is only ever filled on a Bot board — so a room rail counts the room's active cards as well as the archive page, reading `1/4` beside four drawn cards and `0/1` for a Bot whose cards are all still active, while a Bot board's numbers are unchanged. That read without `limit` stays uncapped on purpose: it is the id-lookup and tooling path (`statusOf`, a Bot reading a settled row), not what the board polls. Finishing a card moves it off the active list at once, before the next poll. Tests: `core/test/kanban.test.js`. | +| `core/src/client/kanban.js` | Per-Bot task board (#1058, #1099): the **Tasks** row under Inbox (`KanbanRow`, `ui.board`, `actions.openBoard` / `closeBoard`) and `KanbanDialog`. `boardRail` lists the viewer's Bots nested by `createdBy` and the groups they administer; `boardColumns` maps task status onto **Not started** / **In progress** / **Done**. Cards show id, priority, title, note, assignee and progress, and are drawn by the shared `board-card.js` component: a task card says `source: 'task'`, keys `task:` and keeps `data-task` / `data-column` for this board's own selectors (#1265 step 1). The dialog body is `BoardView` (#1363): the rail stays per-Bot and the columns are the task columns only. The GitHub metrics panel (Today / 7 days, Retry, Diagnose, the package-default repository header) is not mounted. A task card is the shared `BoardCard` with `TASK_SKIN`, and dragging it or dropping it on a column only reports the request to the dialog's `onMove` (`actions.taskUpdate`), so the view still never writes. In a group scope, `columnTree` nests subtasks under the overall task (`kind: 'group'`) when they share a column, and a subtask in another column says which overall task it is part of. That scope also says assigning, claiming, and progress live on the board (overall task, subtask, assignee, column, progress) and that @ only wakes someone. Drag or **Start** / **Complete** calls `actions.taskUpdate`; only the Bot's owner gets those actions. A group scope loads `tasks { roomId }`. Since #1252 the default read is active work only and the Done column merges a second read of the finished rows — `tasks { ...query, archive: true, limit: ARCHIVE_SHOWN }`, which is room-scoped for a group and names the Bot for a Bot board, because the host cuts the page before the board can filter by Bot — newest-settled first by `updatedAt` (tiebreak `createdAt`), finished rows only, and cut to that limit, so a room with hundreds of finished cards still renders a twenty-card column while the poll carries the slice and not the history. Since #2045 that page is also counted for what it is: when the archive holds a full page (`ARCHIVE_SHOWN`), the Done heading reads `20 newest` and a Bot's rail row reads `20+ done` rather than a fraction that would present the page as the history, and in a group the page is cut across the room, so every Bot's share of it is a lower bound and reads the same way rather than presenting its slice as its own total; a page under the limit keeps `{done}/{total}` exactly as before. P-364: the rail adds up the list the scope actually draws from, and a room board keeps its active cards in a second list of its own (`groupTasks`) — `own` is only ever filled on a Bot board — so a room rail counts the room's active cards as well as the archive page, reading `1/4` beside four drawn cards and `0/1` for a Bot whose cards are all still active, while a Bot board's numbers are unchanged. P-360: a Bot board's load effect is keyed by the Bot it shows, so switching the rail re-reads the new Bot's active list and archive page — the page it kept was that of the Bot it left, and filtering it by the new Bot emptied its Done column while the poll kept refilling the old one. That read without `limit` stays uncapped on purpose: it is the id-lookup and tooling path (`statusOf`, a Bot reading a settled row), not what the board polls. Finishing a card moves it off the active list at once, before the next poll. Tests: `core/test/kanban.test.js`. | | `core/src/client/standing-approvals.js` | Standing tool approvals (#540): `StandingApprovalsCard`, the **Always allowed** card in Bot settings (the Bot's grants) and Group settings (the grants this person gave in the group), one row per kind with **Remove** (`actions.removeStandingApproval`). The approval cards (`member-status.js` `ApprovalCard`, the inbox card) add **Always allow for this Bot** and, for an ask from a group, **Always allow for this group** (`alwaysChoices`; never on a friend's Bot). Host side in [core-host](core-host.md#standing-tool-approvals-540). Tests: `core/test/member-status.test.js`, `core/test/standing-approvals.test.js`. | | `core/src/client/mobile.js` | Phone-width layout, step 1 (#362): `MOBILE_CSS` under `@media (max-width:640px)` for held-reply and floating approval buttons, the chat pointers and Agent inbox cards. Tests: `core/test/mobile-layout.test.js`. | diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index c17c93772..6316f2440 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -10099,7 +10099,7 @@ diff --git a/client.js b/client.js + const [own, setOwn] = (0, import_react15.useState)([]); + const [groupTasks, setGroupTasks] = (0, import_react15.useState)([]); + const [archive, setArchive] = (0, import_react15.useState)([]); -+ const key = scope?.kind === "group" ? `group:${scope.roomId}` : "own"; ++ const key = scope?.kind === "group" ? `group:${scope.roomId}` : `bot:${scope.botId ?? ""}`; + (0, import_react15.useEffect)(() => { + let stopped = false; + const load = () => { diff --git a/patches/ds-bot/files/src/client/kanban.js b/patches/ds-bot/files/src/client/kanban.js index 65066cd34..c1c8c4857 100644 --- a/patches/ds-bot/files/src/client/kanban.js +++ b/patches/ds-bot/files/src/client/kanban.js @@ -167,7 +167,9 @@ export function useBoardTasks(actions, scope) { const [own, setOwn] = useState([]) const [groupTasks, setGroupTasks] = useState([]) const [archive, setArchive] = useState([]) - const key = scope?.kind === 'group' ? `group:${scope.roomId}` : 'own' + // P-360: a Bot board's archive page names that Bot, so the key has to name it too — a shared + // `'own'` kept the first Bot's page (and its poll) after the rail switched to another Bot. + const key = scope?.kind === 'group' ? `group:${scope.roomId}` : `bot:${scope.botId ?? ''}` useEffect(() => { let stopped = false const load = () => { diff --git a/patches/ds-bot/files/test/kanban.test.js b/patches/ds-bot/files/test/kanban.test.js index e0bc63dc9..c8b5ffc7b 100644 --- a/patches/ds-bot/files/test/kanban.test.js +++ b/patches/ds-bot/files/test/kanban.test.js @@ -430,3 +430,165 @@ test('P-364: a room board rail counts the room\'s active cards, not only its arc assert.equal(room.railMeta('s'), '1/4', 'four drawn cards, one of them finished: {done}/{total}, not {done}/{done}') assert.equal(room.railMeta('a'), '0/1', 'a Bot with an active card and none finished is 0 of 1, not a blank rail') }) + +// P-360: the rail switch from one Bot to another. The fake React at the top of this file hands an +// effect back to the test and never re-runs one, so a switch case written against it passes whatever +// the hook does (#1810). This is a second, stateful context: hook slots per component, an effect +// re-run only when its deps change, a setter that re-renders, and the board's poll on a fake clock. +function mountBoard(actions) { + const h = (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }) + const runtime = { instances: new Map(), key: 'root', index: 0, dirty: false, timer: 0, timers: new Map() } + const instance = (key) => { + let found = runtime.instances.get(key) + if (!found) { + found = { slots: [], refs: [], pending: [], deps: new Map(), cleanups: new Map() } + runtime.instances.set(key, found) + } + return found + } + const scoped = (key, run) => { + const previousKey = runtime.key + const previousIndex = runtime.index + runtime.key = key + runtime.index = 0 + try { return run() } finally { runtime.key = previousKey; runtime.index = previousIndex } + } + const react = { + createElement: h, Fragment: Symbol('fragment'), + useState: (initial) => { + const slot = instance(runtime.key) + const index = runtime.index++ + if (!(index in slot.slots)) slot.slots[index] = typeof initial === 'function' ? initial() : initial + const set = (next) => { + const value = typeof next === 'function' ? next(slot.slots[index]) : next + if (Object.is(value, slot.slots[index])) return + slot.slots[index] = value + runtime.dirty = true + } + return [slot.slots[index], set] + }, + useEffect: (run, deps) => { instance(runtime.key).pending.push({ index: runtime.index++, run, deps }) }, + useLayoutEffect: (run, deps) => { instance(runtime.key).pending.push({ index: runtime.index++, run, deps }) }, + useMemo: fn => fn(), useCallback: fn => fn(), + useRef: (current) => { + const slot = instance(runtime.key) + const index = runtime.index++ + if (!(index in slot.refs)) slot.refs[index] = { current } + return slot.refs[index] + }, + } + const commit = () => { + for (const slot of runtime.instances.values()) { + const pending = slot.pending + slot.pending = [] + for (const { index, run, deps } of pending) { + const previous = slot.deps.get(index) + const changed = !deps || !previous || deps.length !== previous.length || deps.some((dep, at) => !Object.is(dep, previous[at])) + if (!changed) continue + const cleanup = slot.cleanups.get(index) + if (typeof cleanup === 'function') cleanup() + slot.cleanups.delete(index) + slot.deps.set(index, deps ? [...deps] : null) + const next = run() + if (typeof next === 'function') slot.cleanups.set(index, next) + } + } + } + const expandLive = (node, path = 'r') => { + if (node === null || node === undefined) return null + if (Array.isArray(node)) return node.map((child, at) => expandLive(child, `${path}.${at}`)) + if (typeof node !== 'object') return node + if (typeof node.type === 'function') return scoped(path, () => expandLive(node.type({ ...node.props, children: node.children }), path)) + return { ...node, children: (node.children ?? []).map((child, at) => expandLive(child, `${path}.${at}`)) } + } + const env = { + module: { exports: {} }, + setTimeout, clearTimeout, + // The dialog's escape layer and its `#task=` deep link touch the page; neither is what this + // case reads, so they get the smallest window that lets the board mount. + window: { location: { hash: '' }, addEventListener() {}, removeEventListener() {} }, + setInterval: (fn, ms) => { + const id = ++runtime.timer + runtime.timers.set(id, { every: Math.max(1, Number(ms) || 1), fn }) + return id + }, + clearInterval: (id) => runtime.timers.delete(id), + } + env.require = (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: (node) => node } + throw new Error(`no platform module ${name}`) + } + runInNewContext(bundle.outputFiles[0].text, env) + const live = env.module.exports + let tree = null + const draw = () => { + runtime.dirty = false + for (const slot of runtime.instances.values()) slot.pending = [] + tree = expandLive(h(live.KanbanDialog, { roster, actions })) + commit() + return tree + } + const settle = async () => { + for (let guard = 0; guard < 50; guard += 1) { + await new Promise(resolve => setImmediate(resolve)) + if (!runtime.dirty) return tree + draw() + } + throw new Error('the board never settled') + } + const rail = (botId) => { + const button = all(tree).find(node => node.type === 'button' && node.props?.['data-kind'] === 'bot' && node.props?.['data-id'] === botId) + assert.ok(button, `the rail names Bot ${botId}`) + button.props.onClick() + } + const column = (name) => byClass(tree, 'bt-board-column').find(node => node.props['data-column'] === name) + return { draw, settle, rail, column, get tree() { return tree } } +} + +// A Bot board's Done column is that Bot's own archive page. Switching the rail therefore has to +// send a fresh `{ botId, archive: true, limit: 20 }` read: the loaded page is scoped to the Bot it +// was asked for, and the column filters it by the newly selected Bot, so a page kept from the +// previous Bot is filtered down to nothing and the Done column reads empty. +test('P-360: switching the rail re-reads the archive, and the new Bot draws its own Done page', async () => { + const calls = [] + const finished = (botId, count) => Array.from({ length: count }, (_, index) => ({ + id: `task-${botId}-done-${index}`, ownerBotId: botId, title: `${botId.toUpperCase()} finished ${index}`, + status: 'accepted', progress: 100, createdAt: index, updatedAt: 100 + index, + })) + const pages = { a: finished('a', ARCHIVE_SHOWN), s: finished('s', ARCHIVE_SHOWN) } + const actions = { + closeBoard: () => {}, + boardClose: () => {}, + tasks: (query) => { + calls.push({ ...query }) + return Promise.resolve(query?.archive ? pages[query.botId] ?? [] : []) + }, + } + const board = mountBoard(actions) + const archiveReads = () => calls.filter(query => query?.archive).map(query => query.botId) + + board.draw() + await board.settle() + // The board opens on the first Bot of the rail (Chief `a`). + assert.deepEqual(archiveReads(), ['a'], 'the board opens on the first Bot and reads its archive') + assert.equal(byClass(board.column('done'), 'bt-board-card').length, ARCHIVE_SHOWN, 'Chief draws its own finished cards') + assert.match(textOf(board.column('done')), /A finished 19/) + + board.rail('s') + await board.settle() + // The switch has to ask for Writer's page, not keep Chief's: the loaded rows are Chief's, and the + // column filters them by Writer, so a kept page is an empty column. + assert.deepEqual(archiveReads(), ['a', 's'], 'switching to Writer asks for Writer\'s archive page') + assert.deepEqual(calls.filter(query => query?.archive).at(-1), { botId: 's', archive: true, limit: ARCHIVE_SHOWN }, 'the new read names the new Bot') + assert.equal(byClass(board.column('done'), 'bt-board-card').length, ARCHIVE_SHOWN, 'Writer draws its own finished cards, not an empty column') + assert.match(textOf(board.column('done')), /S finished 19/) + assert.doesNotMatch(textOf(board.column('done')), /A finished/) + + // And back again: every switch re-reads, not only the first one. + board.rail('a') + await board.settle() + assert.deepEqual(archiveReads(), ['a', 's', 'a'], 'switching back to Chief reads Chief\'s page again') + assert.equal(byClass(board.column('done'), 'bt-board-card').length, ARCHIVE_SHOWN, 'Chief draws its own finished cards again') + assert.match(textOf(board.column('done')), /A finished 19/) +})