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() } + }) +})