From c530cc16a871608a133a803d2260472eefdc9076 Mon Sep 17 00:00:00 2001 From: FlyBot Dev-3 Date: Tue, 3 Nov 2026 01:43:09 +0000 Subject: [PATCH] fix(workflow): the Hand a task dialog says how many cards and Bots it is not showing (#2244) Both choice lists were cut at twelve with no row saying so: `loadTasks` dropped every board card after the twelfth before the dialog saw it, and the Bot list sliced at the same point, so the thirteenth card and the thirteenth Bot could not be picked while the dialog looked complete. The dialog now caps its own two lists at twelve, draws a `Show {count} more cards` / `Show {count} more Bots` row with `aria-expanded` that unfolds to the rest and folds back to `Show less`, and the board read keeps every card it loads. A list of twelve or fewer draws no row, and the assign body is unchanged. --- ...44-assign-dialog-lists-expandable.fixed.md | 1 + docs/subsystems/workflow.md | 2 +- plugins/workflow/client.js | 29 +++- plugins/workflow/test/client.test.js | 130 ++++++++++++++++++ 4 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 changelog.d/2244-assign-dialog-lists-expandable.fixed.md diff --git a/changelog.d/2244-assign-dialog-lists-expandable.fixed.md b/changelog.d/2244-assign-dialog-lists-expandable.fixed.md new file mode 100644 index 000000000..b09254903 --- /dev/null +++ b/changelog.d/2244-assign-dialog-lists-expandable.fixed.md @@ -0,0 +1 @@ +Hand a task to a Workflow no longer cuts its card and Bot lists at twelve without saying so: each list draws twelve and a row says how many are left, and expanding it offers every card and every local Bot ([#2244](https://github.com/ThinkFlowLab/FlyBot/issues/2244)). diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index fe0a1bd4c..e82c8edc5 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -41,7 +41,7 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, | `plugins/workflow/src/match.js` | Which open card matches a Workflow or a local Bot. | | `plugins/workflow/src/template.js` | Factory delivery/weekly graphs plus `EARLY_END_GUIDANCE` for Claim/Verify/Triage early-end markers; 发布交付流水线 (a parallel branch) and the weekly report, each node with the inputs and outputs that edge carries. | | `plugins/workflow/src/narrate.js` | Progress lines for a step. A live run quotes the Bot reply when present; otherwise delivery-template nodes may use canned copy. A join line is only for a rank of more than one node. | -| `plugins/workflow/client.js` | Sidebar section (with mini-flowcharts), workflow stage (view/control: one card per live run with its own title, node line, segment progress, status chip, and Pause / Resume / Stop; click a card or its Flow button to focus that run in the drawer flowchart; optional aggregate chip when several run or the queue is non-empty — no message composer), assign dialog (scrollable with sticky footer; it selects the first card once the board loads, keeps `Assign` disabled until a card is selected, and shows a failed Tasks load as an error with a `Retry` instead of the empty-board hint, #1457, #1458), flowchart drawer (collapsed by default, per-node inspector, fitted to the 400px panel so the graph needs no inner horizontal scroll), and Settings → Workflow graphs (canvas-first with inspector panel). The product UI starts only live runs (assign, ingest). The stage shows the queue and refreshes state while runs are active; a running node uses a breathing border animation. Visual flowcharts replace text chains across all surfaces. Sidebar rows (including factory ids such as `delivery`) support Delete with confirm. A row with live runs draws the number beside the dot and carries the count, with the word `running`, in a visually hidden `.wf-sr` span — the dot and the digit sit inside an `aria-hidden` wrap, so the number alone would be silent to a reader — and its accessible name therefore stays the name its content gives: the lane's name, the `Workflow` badge, its Project and its preview, plus the count; a row-level `aria-label` replaced all of that and made two lanes that share a name read alike (#2222, #2230). An idle row draws no dot, no count, no digit and no hidden text, so its name stays its own text. The stage and dialogs portal onto `document.body`; a missing container stays in the section tree. A stage control that fails — a graph save, Reset, Pause, Resume or Stop that throws, rejects, answers `{ok:false}` or does not answer within 10 s — ends on the section's `.wf-err` alert instead of being swallowed, and a success clears the alert. The flowchart's node role sub-label takes its colour from `--bt-ink-2` rather than a hardcoded grey, so it stays readable on every theme's node card (#1495). The drawer's styling reuses the core `--bt-*` tokens and the Bot details drawer's patterns (title, pill tabs, soft pill buttons, bubble-surfaced node cards; accent tints mixed from `--bt-accent`, #1654). The stage lists the tasks the open workflow holds (`heldTasks`, with a `{count} more held tasks` summary when `heldTaskCount` exceeds the cap), each with one **Re-match** action that drops that task's claim and matches it again through `workflow/ingest` with `rematch: true` — one board read to hand over the card's fields and one state read after, never a second polling loop (#1647). The drawer's Edit plane keeps one copy of the row geometry: a graph whose parallel cards still fit is the fitted plane (scaled centres, `parallelFrameStyle` frame, one-fold `wirePath`), and one that cannot is laid out in `flowLayout`'s own rows — its cards are those boxes, its dashed frame is that band's frame, and its wires are the `flowWires` routing around them, so a 3+ way step at a 364px drawer keeps its frame inside the panel and no wire crosses a card (#1693). A two-Bot parallel step the fitted plane would squeeze — the drawer's fit leaves the pair 4px apart, with each card's out-port (5px) and remove button (8px) past its right edge under the neighbour — is drawn at that row's `flowLayout` column width (154px at a 364px drawer) on its fitted centres and wires, keeping the 12px row gap and a plane whose drags still commit world coordinates; `parallelFrameStyle` adds the 20px label band above the cards and a 10px pad below them, so the fitted frame encloses every card it names (#1738). | +| `plugins/workflow/client.js` | Sidebar section (with mini-flowcharts), workflow stage (view/control: one card per live run with its own title, node line, segment progress, status chip, and Pause / Resume / Stop; click a card or its Flow button to focus that run in the drawer flowchart; optional aggregate chip when several run or the queue is non-empty — no message composer), assign dialog (scrollable with sticky footer; it selects the first card once the board loads, keeps `Assign` disabled until a card is selected, and shows a failed Tasks load as an error with a `Retry` instead of the empty-board hint, #1457, #1458; both of its choice lists draw twelve at a time and a `Show {count} more cards` / `Show {count} more Bots` row says how many they are not showing, expanding to the rest and folding back to `Show less`, so no card or local Bot is out of reach while the default view keeps its length, #2244), flowchart drawer (collapsed by default, per-node inspector, fitted to the 400px panel so the graph needs no inner horizontal scroll), and Settings → Workflow graphs (canvas-first with inspector panel). The product UI starts only live runs (assign, ingest). The stage shows the queue and refreshes state while runs are active; a running node uses a breathing border animation. Visual flowcharts replace text chains across all surfaces. Sidebar rows (including factory ids such as `delivery`) support Delete with confirm. A row with live runs draws the number beside the dot and carries the count, with the word `running`, in a visually hidden `.wf-sr` span — the dot and the digit sit inside an `aria-hidden` wrap, so the number alone would be silent to a reader — and its accessible name therefore stays the name its content gives: the lane's name, the `Workflow` badge, its Project and its preview, plus the count; a row-level `aria-label` replaced all of that and made two lanes that share a name read alike (#2222, #2230). An idle row draws no dot, no count, no digit and no hidden text, so its name stays its own text. The stage and dialogs portal onto `document.body`; a missing container stays in the section tree. A stage control that fails — a graph save, Reset, Pause, Resume or Stop that throws, rejects, answers `{ok:false}` or does not answer within 10 s — ends on the section's `.wf-err` alert instead of being swallowed, and a success clears the alert. The flowchart's node role sub-label takes its colour from `--bt-ink-2` rather than a hardcoded grey, so it stays readable on every theme's node card (#1495). The drawer's styling reuses the core `--bt-*` tokens and the Bot details drawer's patterns (title, pill tabs, soft pill buttons, bubble-surfaced node cards; accent tints mixed from `--bt-accent`, #1654). The stage lists the tasks the open workflow holds (`heldTasks`, with a `{count} more held tasks` summary when `heldTaskCount` exceeds the cap), each with one **Re-match** action that drops that task's claim and matches it again through `workflow/ingest` with `rematch: true` — one board read to hand over the card's fields and one state read after, never a second polling loop (#1647). The drawer's Edit plane keeps one copy of the row geometry: a graph whose parallel cards still fit is the fitted plane (scaled centres, `parallelFrameStyle` frame, one-fold `wirePath`), and one that cannot is laid out in `flowLayout`'s own rows — its cards are those boxes, its dashed frame is that band's frame, and its wires are the `flowWires` routing around them, so a 3+ way step at a 364px drawer keeps its frame inside the panel and no wire crosses a card (#1693). A two-Bot parallel step the fitted plane would squeeze — the drawer's fit leaves the pair 4px apart, with each card's out-port (5px) and remove button (8px) past its right edge under the neighbour — is drawn at that row's `flowLayout` column width (154px at a 364px drawer) on its fitted centres and wires, keeping the 12px row gap and a plane whose drags still commit world coordinates; `parallelFrameStyle` adds the 20px label band above the cards and a 10px pad below them, so the fitted frame encloses every card it names (#1738). | ## Entry points diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index 986409649..3db270c22 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -145,6 +145,8 @@ window.__ModuleLoader__.load({ 'Parallel branches · Merge after all pass': '并行分支 · 全部通过后汇合', 'Show more': '展开', 'Show less': '收起', + 'Show {count} more cards': '展开另外 {count} 张卡片', + 'Show {count} more Bots': '展开另外 {count} 个 Bot', // Stage timeline (#1489). The key is the English line; service.js and // narrate.js keep the Chinese text only as the fallback for rows written // before the key existed. @@ -1414,12 +1416,16 @@ window.__ModuleLoader__.load({ h('button', { type: 'button', className: 'wf-btn wf-danger', onClick: onConfirm }, tr('Delete'))))) } + const ASSIGN_VISIBLE = 12 + function AssignModal({ workflows, tasks, tasksStatus, tasksError, error, projects, roster, workflowId, onClose, onConfirm, onRetry }) { // #2241: two lanes drawn from different Projects read the same inside this dialog, so the // target row repeats the badge the list row already draws — the same `projects` listing. const projectName = (id) => (projects ?? []).find(project => project.id === id)?.name ?? '' const [taskId, setTaskId] = useState(tasks[0]?.id ?? '') const [target, setTarget] = useState(workflowId || workflows[0]?.id || '') + const [tasksExpanded, setTasksExpanded] = useState(false) + const [botsExpanded, setBotsExpanded] = useState(false) // #1457: the dialog opens before loadTasks() answers, so the first client render can have no // rows; select the first card once they arrive, or when the selected card is gone. useEffect(() => { @@ -1434,7 +1440,9 @@ window.__ModuleLoader__.load({ if (seen.has(bot.name)) return false seen.add(bot.name) return true - }).slice(0, 12) + }) + const shownTasks = tasksExpanded ? tasks : tasks.slice(0, ASSIGN_VISIBLE) + const shownBots = botsExpanded ? bots : bots.slice(0, ASSIGN_VISIBLE) return h('div', { className: 'wf-mask', onClick: (event) => { if (event.target === event.currentTarget) onClose() } }, h('div', { className: 'wf-modal', role: 'dialog', 'aria-modal': true, 'aria-label': tr('Hand a task to a Workflow') }, h('div', { className: 'wf-modal-header' }, @@ -1451,12 +1459,16 @@ window.__ModuleLoader__.load({ ? h('div', { className: 'wf-muted wf-cards-empty' }, tr('Loading the Tasks board…')) : tasks.length === 0 ? h('div', { className: 'wf-muted wf-cards-empty' }, tr('No open tasks on the Tasks board.')) - : h('div', { className: 'wf-cards' }, tasks.map(task => h('button', { + : [h('div', { className: 'wf-cards' }, shownTasks.map(task => h('button', { key: task.id, type: 'button', className: `wf-kcard ${taskId === task.id ? 'sel' : ''}`, onClick: () => setTaskId(task.id), }, h('div', { className: 'wf-id' }, `${shortId(task.id)} · ${priorityOf(task)}`), h('div', { className: 'wf-tt' }, task.title), h('div', { className: 'wf-kcol' }, `● ${tr(COLUMN_KEY[columnOf(task)] ?? 'Not started')}`)))), + tasks.length > ASSIGN_VISIBLE + ? h('button', { type: 'button', className: 'wf-more', 'data-more': 'tasks', 'aria-expanded': tasksExpanded, onClick: () => setTasksExpanded(value => !value) }, + tasksExpanded ? tr('Show less') : tr('Show {count} more cards', { count: tasks.length - shownTasks.length })) + : null], // #2237: a refused assign stays in this dialog, so the sentence the host sent is // drawn here; the page-level copy is suppressed while this dialog is open. error ? h('div', { className: 'wf-err', role: 'alert' }, error) : null, @@ -1472,14 +1484,18 @@ window.__ModuleLoader__.load({ projectName(item.projectId) ? h('span', { className: 'wf-proj' }, projectName(item.projectId)) : null), h('span', { className: 'wf-chain' }, item.chain)), h('span', { className: 'wf-rad' }))), - bots.map(bot => h('button', { + shownBots.map(bot => h('button', { key: bot.id, type: 'button', className: `wf-tgt ${target === `bot:${bot.id}` ? 'sel' : ''}`, onClick: () => setTarget(`bot:${bot.id}`), }, h(Mark, { node: { name: bot.name, botId: bot.id }, roster, size: 30 }), h('span', null, h('span', { className: 'wf-t1' }, bot.name), h('span', { className: 'wf-chain' }, tr('One Bot (as before)'))), - h('span', { className: 'wf-rad' }))))), + h('span', { className: 'wf-rad' }))), + bots.length > ASSIGN_VISIBLE + ? h('button', { type: 'button', className: 'wf-more', 'data-more': 'bots', 'aria-expanded': botsExpanded, onClick: () => setBotsExpanded(value => !value) }, + botsExpanded ? tr('Show less') : tr('Show {count} more Bots', { count: bots.length - shownBots.length })) + : null)), h('div', { className: 'wf-modal-footer' }, h('button', { type: 'button', className: 'wf-btn', onClick: onClose }, tr('Cancel')), h('button', { type: 'button', className: 'wf-btn wf-dark', disabled: !canAssign, onClick: () => { if (canAssign) onConfirm({ taskId, target }) } }, tr('Assign'))))) @@ -2089,8 +2105,10 @@ window.__ModuleLoader__.load({ } try { const rows = await host('tasks', {}) + // #2244: the dialog caps its own two lists and says how many it is not showing, so + // this read must not drop a card the owner can still pick. const real = Array.isArray(rows) - ? rows.filter(task => task && task.title && task.status !== 'cancelled').slice(0, 12).map(task => ({ + ? rows.filter(task => task && task.title && task.status !== 'cancelled').map(task => ({ id: task.id, title: task.title, status: task.status, priority: task.priority, column: columnOf(task), })) : [] @@ -2440,6 +2458,7 @@ window.__ModuleLoader__.load({ .wf-canvas[data-fit="drawer"] .wf-term.wf-term-clamp .wf-term-text{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden} .wf-term-text{display:block} .wf-term-more{display:block;margin-top:2px;padding:0;border:0;background:none;color:var(--bt-accent,#4d6bfe);font:inherit;font-size:11.5px;cursor:pointer} +.wf-more{display:block;margin-top:8px;padding:0;border:0;background:none;color:var(--bt-accent,#4d6bfe);font:inherit;font-size:12px;cursor:pointer} .wf-flow-view .wf-node{box-sizing:border-box} .wf-flow-view .wf-plabel{width:max-content;box-sizing:border-box;white-space:normal} .wf-flow-view .wf-wires path{stroke-linejoin:round} diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index 71308c23b..c8ca26db7 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -4215,3 +4215,133 @@ test('#2241: picking a lane still posts the same assign body', async () => { view.unmount() }) }) +// #2244: the assign dialog cut both choice lists to twelve with no "more" row, so the +// thirteenth card and the thirteenth Bot could not be picked while the dialog looked +// complete. The dialog keeps today's twelve-item view and says how many it is not showing; +// the expander is the file's own `aria-expanded` row, marked by `data-more`. +const assignBoard = (count) => Array.from({ length: count }, (_, i) => ({ id: `task-${i + 1}`, title: `Board card ${i + 1}`, status: 'open', priority: 'P1' })) +const localBots = (count) => Array.from({ length: count }, (_, i) => ({ id: `bot-${i + 1}`, name: `Bot ${i + 1}` })) + +async function withBoardHost(rows, fn) { + const prev = globalThis.__flybot__ + globalThis.__flybot__ = { host: async (channel) => (channel === 'tasks' ? rows : []) } + try { return await fn() } finally { globalThis.__flybot__ = prev } +} + +async function openAssignWith({ roster }) { + const graph = delivery() + const { call, calls } = assignCall(graph, () => ({ ok: true })) + const view = mount(client.Section, { roster, call, actions: {} }) + await settle() + await openAssign(view) + const className = node => String(node.props?.className ?? '') + return { + view, + calls, + cards: () => findAll(view.tree, node => className(node).startsWith('wf-kcard')), + bots: () => findAll(view.tree, node => className(node).startsWith('wf-tgt') && textOf(node).includes('One Bot (as before)')), + moreRow: (which) => findAll(view.tree, node => node.type === 'button' && node.props?.['data-more'] === which)[0], + posted: () => calls.filter(item => item.name === 'workflow/assign'), + } +} + +test('#2244: the card list says how many cards it is not showing', async () => { + await withBoardHost(assignBoard(20), async () => { + const ui = await openAssignWith({ roster: { bots: [] } }) + try { + assert.equal(ui.cards().length, 12, 'the dialog still opens on today\'s twelve cards') + const row = ui.moreRow('tasks') + assert.ok(row, 'the card list says the list continues') + assert.equal(row.props['aria-expanded'], false) + assert.equal(textOf(row), 'Show 8 more cards') + } finally { ui.view.unmount() } + }) +}) + +test('#2244: expanding the card list offers the twentieth card', async () => { + await withBoardHost(assignBoard(20), async () => { + const ui = await openAssignWith({ roster: { bots: [] } }) + try { + const row = ui.moreRow('tasks') + assert.ok(row, 'the card list can be expanded') + row.props.onClick() + await settle() + assert.equal(ui.cards().length, 20, 'every open card is offered once expanded') + const last = ui.cards().find(card => textOf(card).includes('Board card 20')) + assert.ok(last, 'the twentieth card is offered') + assert.equal(ui.moreRow('tasks').props['aria-expanded'], true) + assert.equal(textOf(ui.moreRow('tasks')), 'Show less') + last.props.onClick() + await settle() + assignOf(dialogOf(ui.view)).props.onClick() + await settle() + assert.equal(ui.posted().length, 1, 'the click posts one assign') + assert.deepEqual(ui.posted()[0].body, { workflowId: 'delivery', taskId: 'task-20', title: 'Board card 20' }) + } finally { ui.view.unmount() } + }) +}) + +test('#2244: twelve cards keep today\'s dialog and the assign body', async () => { + await withBoardHost(assignBoard(12), async () => { + const ui = await openAssignWith({ roster: { bots: [] } }) + try { + assert.equal(ui.cards().length, 12) + assert.equal(ui.moreRow('tasks'), undefined, 'nothing is hidden, so no more row is drawn') + assignOf(dialogOf(ui.view)).props.onClick() + await settle() + assert.equal(ui.posted().length, 1) + assert.deepEqual(ui.posted()[0].body, { workflowId: 'delivery', taskId: 'task-1', title: 'Board card 1' }) + } finally { ui.view.unmount() } + }) +}) + +test('#2244: the Bot list says how many Bots it is not showing', async () => { + await withBoardHost(assignBoard(2), async () => { + const ui = await openAssignWith({ roster: { bots: localBots(20) } }) + try { + assert.equal(ui.bots().length, 12, 'the pick list still opens on today\'s twelve Bots') + const row = ui.moreRow('bots') + assert.ok(row, 'the Bot list says the list continues') + assert.equal(row.props['aria-expanded'], false) + assert.equal(textOf(row), 'Show 8 more Bots') + } finally { ui.view.unmount() } + }) +}) + +test('#2244: expanding the Bot list offers the twentieth Bot', async () => { + await withBoardHost(assignBoard(2), async () => { + const ui = await openAssignWith({ roster: { bots: localBots(20) } }) + try { + const row = ui.moreRow('bots') + assert.ok(row, 'the Bot list can be expanded') + row.props.onClick() + await settle() + assert.equal(ui.bots().length, 20, 'every local Bot is offered once expanded') + const last = ui.bots().find(node => textOf(node).includes('Bot 20')) + assert.ok(last, 'the twentieth Bot is offered') + assert.equal(textOf(ui.moreRow('bots')), 'Show less') + last.props.onClick() + await settle() + assignOf(dialogOf(ui.view)).props.onClick() + await settle() + assert.equal(ui.posted().length, 1, 'the click posts one assign') + assert.deepEqual(ui.posted()[0].body, { botId: 'bot-20', taskId: 'task-1', title: 'Board card 1' }) + } finally { ui.view.unmount() } + }) +}) + +test('#2244: three Bots keep today\'s pick list and the bot:2 body', async () => { + await withBoardHost(assignBoard(2), async () => { + const ui = await openAssignWith({ roster: { bots: localBots(3) } }) + try { + assert.equal(ui.bots().length, 3) + assert.equal(ui.moreRow('bots'), undefined, 'nothing is hidden, so no more row is drawn') + ui.bots().find(node => textOf(node).includes('Bot 2')).props.onClick() + await settle() + assignOf(dialogOf(ui.view)).props.onClick() + await settle() + assert.equal(ui.posted().length, 1) + assert.deepEqual(ui.posted()[0].body, { botId: 'bot-2', taskId: 'task-1', title: 'Board card 1' }) + } finally { ui.view.unmount() } + }) +})