Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2495-workflow-panel-assign-brief.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Handing a card to a Workflow from the panel now carries the card's own facts into the run instead of dropping them: the assign body sends the card's `brief` verbatim, and an explicit empty one when the card holds none, so a brief cached by an earlier call cannot come back ([#2495](https://github.com/ThinkFlowLab/FlyBot/issues/2495)).
2 changes: 1 addition & 1 deletion docs/subsystems/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,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; 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, and a card picked past the twelfth is drawn first once the list is folded again, so the twelve rows never hide the card the button is about to send (#2257), #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`; when `heldTaskCount` exceeds that page the rest is a `Show {count} more held tasks` control — a `wf-more` button with `data-more="held"` and `aria-expanded`, whose count is what pressing it reaches past the rows drawn rather than everything the lane holds — that reads the wider page with `heldLimit: 200` and folds back to `Show less`; that page stops at 200, so an unfolded list still short of `heldTaskCount` draws a `wf-held-note` line `Showing {shown} of {count} held tasks`, and neither the count nor the button promises an id it cannot show (#2256, #2260), 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; the hand-off carries the card's own facts — the assign body sends the card's `brief` verbatim and an explicit empty one when the card holds none, so a run never starts on the title where the card had a work order and a brief an earlier call cached cannot come back (#2495); 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, and a card picked past the twelfth is drawn first once the list is folded again, so the twelve rows never hide the card the button is about to send (#2257), #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`; when `heldTaskCount` exceeds that page the rest is a `Show {count} more held tasks` control — a `wf-more` button with `data-more="held"` and `aria-expanded`, whose count is what pressing it reaches past the rows drawn rather than everything the lane holds — that reads the wider page with `heldLimit: 200` and folds back to `Show less`; that page stops at 200, so an unfolded list still short of `heldTaskCount` draws a `wf-held-note` line `Showing {shown} of {count} held tasks`, and neither the count nor the button promises an id it cannot show (#2256, #2260), 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

Expand Down
8 changes: 7 additions & 1 deletion plugins/workflow/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -2195,6 +2195,9 @@ window.__ModuleLoader__.load({
const real = Array.isArray(rows)
? 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),
// #2495: the card is the authority on its own facts, and this list is the panel's
// only read of the card — dropping `brief` here is what made the hand-off title-only.
brief: task.brief,
}))
: []
setTasks(real)
Expand Down Expand Up @@ -2268,7 +2271,10 @@ window.__ModuleLoader__.load({
actions?.openSession?.(botId)
return
}
const value = await call('workflow/assign', { workflowId: target, taskId: task.id, title: task.title })
// #2495: hand the card's own facts over, and say so explicitly even when it holds none —
// an omitted `brief` keeps whatever an earlier call cached under this task id.
const brief = typeof task.brief === 'string' ? task.brief : ''
const value = await call('workflow/assign', { workflowId: target, taskId: task.id, title: task.title, brief })
if (value?.ok === false) { setError(value.error ?? ''); return }
setPack(value)
setOpenId(target)
Expand Down
78 changes: 73 additions & 5 deletions plugins/workflow/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2543,6 +2543,74 @@ test('#1457: an empty board keeps Assign disabled, so the click is not a silent
}
})

// #2495: Hand a task to a Workflow is the panel's own way into `workflow/assign`, and it built the
// body from the card's id and title only. A card that held facts arrived at the new run with no
// brief; the service falls back to the title, so the work order vanished without a word.
test('#2495: a card that holds facts hands them to the workflow verbatim', async () => {
const graph = delivery()
const calls = []
const call = async (name, body) => {
calls.push({ name, body })
if (name === 'workflow/state') return { workflows: [graph], templates: [] }
return { ok: true, workflows: [graph] }
}
const prev = globalThis.__flybot__
globalThis.__flybot__ = {
host: async (channel) => (channel !== 'tasks' ? [] : [
{ id: 'task-facts', title: 'Ship the release notes', status: 'open', priority: 'P1', brief: 'FACTS A\nSecond line.' },
]),
}
try {
const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} })
await settle()
findAll(view.tree, node => node.props?.className === 'wf-row wf-assign-row')[0].props.onClick()
await settle()
const assign = findAll(view.tree, node => node.type === 'button' && textOf(node).startsWith('Assign'))[0]
assign.props.onClick()
await settle()
const posted = calls.filter(item => item.name === 'workflow/assign')
assert.equal(posted.length, 1, 'the click posts one assign')
assert.equal(posted[0].body.brief, 'FACTS A\nSecond line.', 'the facts ride with the hand-off, verbatim')
view.unmount()
} finally {
globalThis.__flybot__ = prev
}
})

// #2495: a card that holds none sends an explicit empty brief. Leaving the key out would let the
// service keep whatever a much earlier call cached under that task id, and the panel is the caller
// that knows the card holds nothing now (#2490's non-blocking hole).
test('#2495: a card with no facts sends an empty brief, so a cached one cannot come back', async () => {
const graph = delivery()
const calls = []
const call = async (name, body) => {
calls.push({ name, body })
if (name === 'workflow/state') return { workflows: [graph], templates: [] }
return { ok: true, workflows: [graph] }
}
const prev = globalThis.__flybot__
globalThis.__flybot__ = {
host: async (channel) => (channel !== 'tasks' ? [] : [
{ id: 'task-bare', title: 'Ship the release notes', status: 'open', priority: 'P1' },
]),
}
try {
const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} })
await settle()
findAll(view.tree, node => node.props?.className === 'wf-row wf-assign-row')[0].props.onClick()
await settle()
const assign = findAll(view.tree, node => node.type === 'button' && textOf(node).startsWith('Assign'))[0]
assign.props.onClick()
await settle()
const posted = calls.filter(item => item.name === 'workflow/assign')
assert.equal(posted.length, 1, 'the click posts one assign')
assert.equal(posted[0].body.brief, '', 'an explicit empty brief, not a missing key')
view.unmount()
} finally {
globalThis.__flybot__ = prev
}
})

// #1458: a rejected Tasks call used to be rendered as a genuinely empty board,
// so the owner was told there is nothing to assign instead of that the load failed.
test('#1458: a failed Tasks load shows the error and a Retry, never the empty-board hint', async () => {
Expand Down Expand Up @@ -4278,7 +4346,7 @@ test('#2241: picking a lane still posts the same assign body', async () => {
await settle()
const posts = calls.filter(item => item.name === 'workflow/assign')
assert.equal(posts.length, 1, 'one assign is posted')
assert.deepEqual(posts[0].body, { workflowId: 'lane-alpha', taskId: 'task-live-1', title: 'Real board card one' }, 'the submit body is unchanged')
assert.deepEqual(posts[0].body, { workflowId: 'lane-alpha', taskId: 'task-live-1', title: 'Real board card one', brief: '' }, 'the submit body is unchanged')
view.unmount()
})
})
Expand Down Expand Up @@ -4343,7 +4411,7 @@ test('#2244: expanding the card list offers the twentieth card', async () => {
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' })
assert.deepEqual(ui.posted()[0].body, { workflowId: 'delivery', taskId: 'task-20', title: 'Board card 20', brief: '' })
} finally { ui.view.unmount() }
})
})
Expand All @@ -4357,7 +4425,7 @@ test('#2244: twelve cards keep today\'s dialog and the assign body', async () =>
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' })
assert.deepEqual(ui.posted()[0].body, { workflowId: 'delivery', taskId: 'task-1', title: 'Board card 1', brief: '' })
} finally { ui.view.unmount() }
})
})
Expand Down Expand Up @@ -4537,7 +4605,7 @@ test('#2254: the named confirm button posts the same assign body', async () => {
await settle()
const posts = calls.filter(item => item.name === 'workflow/assign')
assert.equal(posts.length, 1, 'one assign is posted')
assert.deepEqual(posts[0].body, { workflowId: 'lane-beta', taskId: 'task-live-1', title: 'Real board card one' }, 'the submit body is unchanged')
assert.deepEqual(posts[0].body, { workflowId: 'lane-beta', taskId: 'task-live-1', title: 'Real board card one', brief: '' }, 'the submit body is unchanged')
view.unmount()
})
})
Expand Down Expand Up @@ -4671,7 +4739,7 @@ test('#2257: collapsing the card list keeps the picked card in view', async () =
assignOf(dialogOf(ui.view)).props.onClick()
await settle()
assert.equal(ui.posted().length, 1, 'one assign is posted')
assert.deepEqual(ui.posted()[0].body, { workflowId: 'delivery', taskId: 'task-20', title: 'Board card 20' }, 'the submit body is unchanged')
assert.deepEqual(ui.posted()[0].body, { workflowId: 'delivery', taskId: 'task-20', title: 'Board card 20', brief: '' }, 'the submit body is unchanged')
} finally { ui.view.unmount() }
})
})
Expand Down
Loading