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/2230-live-row-accessible-name.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A Workflow row with live runs no longer replaces its accessible name with `{name} · {count} running`. The count rides in a visually hidden span beside the `aria-hidden` dot instead, so a reader gets everything the row's content gives — the lane's name, the `Workflow` badge, its Project and its preview — plus how many runs are live. Two lanes that share a name are distinguishable again, and a read-only window still hears the count (#2230).
2 changes: 1 addition & 1 deletion docs/subsystems/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `{name} · {count} running` as its accessible name; an idle row draws no dot, no count and no digit, so its name stays its own text (#2222). 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), 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

Expand Down
13 changes: 9 additions & 4 deletions plugins/workflow/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ window.__ModuleLoader__.load({
'One Bot (as before)': '单个 Bot(原有方式)',
'Concurrent runs': '同时运行',
'{count} running · queue {depth}': '{count} 个在运行 · 队列 {depth}',
'{count} running': '{count} 个在运行',
'{name} · {count} running': '{name} · {count} 个在运行',
Reset: '重置',
Flow: '流程图',
Expand Down Expand Up @@ -2207,9 +2208,6 @@ window.__ModuleLoader__.load({
workflows.length > 0 && shown.length === 0 ? h('div', { className: 'wf-muted' }, tr('No workflows in this Project')) : null,
shown.map(item => h('button', {
key: item.id, type: 'button', className: `wf-row ${openId === item.id ? 'on' : ''}`,
// A row with live runs says how many: the dot is colour only, and a
// reader who cannot see it still needs the number (P-433).
'aria-label': (item.activeCount ?? 0) > 0 ? tr('{name} · {count} running', { name: item.name, count: item.activeCount }) : undefined,
onClick: () => { setOpenId(item.id) },
onContextMenu: locked ? undefined : (event) => {
event.preventDefault()
Expand All @@ -2224,7 +2222,13 @@ window.__ModuleLoader__.load({
h('span', { className: 'wf-t2' }, sidebarPreview(item))),
(item.activeCount ?? 0) > 0 ? h('span', { className: 'wf-live-wrap', 'aria-hidden': true },
h('span', { className: 'wf-live' }),
h('span', { className: 'wf-live-n' }, String(item.activeCount))) : null)),
h('span', { className: 'wf-live-n' }, String(item.activeCount))) : null,
// #2230: the count is the only live text a reader gets, so it cannot ride inside
// the `aria-hidden` wrap: a row-level `aria-label` replaced the name the content
// gives (the lane's name, the Workflow badge, its Project, its preview), and two
// lanes may carry the same name. Hidden text keeps the count without taking those
// away — sighted readers still get the dot and the digit alone.
(item.activeCount ?? 0) > 0 ? h('span', { className: 'wf-sr' }, tr('{count} running', { count: item.activeCount })) : null)),
locked ? null : h('button', { type: 'button', className: 'wf-row wf-assign-row', onClick: () => { setAssignOpen(true); void loadTasks() } },
h('span', { className: 'wf-ico' }, glyph()),
h('span', { className: 'wf-txt' }, h('span', { className: 'wf-t1' }, tr('Hand a task to a Workflow')))),
Expand Down Expand Up @@ -2339,6 +2343,7 @@ window.__ModuleLoader__.load({
.wf-live{width:7px;height:7px;border-radius:50%;background:var(--bt-accent,#4d6bfe);flex:none}
.wf-live-wrap{display:flex;align-items:center;gap:4px;flex:none}
.wf-live-n{font-size:10px;font-weight:650;line-height:1;color:var(--bt-accent,#4d6bfe)}
.wf-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}
.wf-ico{width:32px;height:32px;border-radius:9px;background:var(--bt-card,#fff);border:1px solid var(--bt-line,rgba(20,20,20,.08));display:grid;place-items:center;color:var(--bt-accent,#4d6bfe);flex:none;overflow:hidden;padding:2px;box-sizing:border-box}
.wf-mini-flow{display:block;max-width:100%;max-height:100%}
.wf-mini-flow-compact{width:100%;height:100%}
Expand Down
94 changes: 92 additions & 2 deletions plugins/workflow/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,20 @@
return out
}
const textOf = node => (typeof node === 'string' ? node : node ? (node.children ?? []).map(textOf).join('') : '')
// #2230: the name a reader gets. An `aria-label` replaces the name computed from the
// content; otherwise the content names the element, with every `aria-hidden` subtree
// skipped — the rule a browser applies, so a test can read the same name a reader hears.
const accessibleName = (node) => {
const label = node?.props?.['aria-label']
if (typeof label === 'string' && label !== '') return label
const walk = (child) => {
if (typeof child === 'string') return child
if (!child || typeof child !== 'object') return ''
if (child.props?.['aria-hidden'] === true) return ''
return (child.children ?? []).map(walk).join('')
}
return (node?.children ?? []).map(walk).join('')
}
const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
const htmlOf = (node) => {
if (Array.isArray(node)) return node.map(htmlOf).join('')
Expand Down Expand Up @@ -652,7 +666,7 @@
const count = findAll(view.tree, node => node.props?.className === 'wf-live-n')
assert.equal(count.length, 1, 'one count stands beside the live dot')
assert.equal(textOf(count[0]), '3', 'the count is the number of live runs')
assert.ok(String(row.props['aria-label'] ?? '').includes('3'), 'the accessible name says how many runs are live')
assert.ok(accessibleName(row).includes('3 running'), 'the accessible name says how many runs are live')

row.props.onClick()
await settle()
Expand All @@ -678,7 +692,7 @@
await settle()
const row = findAll(view.tree, node => node.type === 'button' && textOf(node).includes(graph.name))[0]
assert.ok(row, 'the read-only sidebar still lists the workflow')
assert.ok(String(row.props['aria-label'] ?? '').includes('2'), 'the read-only row still names the live count')
assert.ok(accessibleName(row).includes('2 running'), 'the read-only row still names the live count')
assert.equal(textOf(findAll(view.tree, node => node.props?.className === 'wf-live-n')[0]), '2')
assert.equal(findAll(view.tree, node => node.type === 'button' && textOf(node) === 'New').length, 0, 'the window is locked')
} finally {
Expand Down Expand Up @@ -708,6 +722,82 @@
}
})

// #2230 (P-445, regression of #2224): two lanes may legally share a name — `service.js`
// only appends a copy suffix once — so a row-level `aria-label` that replaces the name
// computed from the content made two different lanes read the same words, with neither
// Project nor preview. A live row must keep everything an idle row names, plus the count.
test('#2230: two lanes with one name are told apart by their own Project, and a live row keeps the badge and the preview', async () => {
const alpha = { ...delivery(), id: 'delivery-a', name: 'Delivery', projectId: 'p1', preview: 'Alpha #2214 · step 2/31', activeCount: 1 }
const beta = { ...delivery(), id: 'delivery-b', name: 'Delivery', projectId: 'p2', preview: 'Beta #2219 · step 1/31', activeCount: 1 }
const idle = { ...delivery(), id: 'delivery-c', name: 'Gamma', activeCount: 0, run: null }
const call = async (name) => {
if (name === 'workflow/state') return { workflows: [alpha, beta, idle], templates: [] }
return { ok: true, workflows: [alpha, beta, idle] }
}
const host = async (channel) => {
if (channel === 'projects') return { projects: [{ id: 'p1', name: 'Alpha' }, { id: 'p2', name: 'Beta' }] }
return []
}
const prev = globalThis.__flybot__
globalThis.__flybot__ = { host }
const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} })
try {
await settle()
const rows = findAll(view.tree, node => node.type === 'button' && String(node.props?.className ?? '').includes('wf-row') && textOf(node).includes('Delivery'))
assert.equal(rows.length, 2, 'both lanes with one name are listed')
const [first, second] = rows.map(accessibleName)
assert.notEqual(first, second, 'two lanes a reader must tell apart do not share one name')
assert.ok(first.includes('Alpha'), 'the first row names its own Project')
assert.ok(second.includes('Beta'), 'the second row names its own Project')

const idleRow = findAll(view.tree, node => node.type === 'button' && String(node.props?.className ?? '').includes('wf-row') && textOf(node).includes('Gamma'))[0]
const idleName = accessibleName(idleRow)
assert.ok(idleName.includes('Workflow') && idleName.includes('Waiting for a task'), 'an idle row is named by its badge and its preview')
for (const [i, name] of rows.map(accessibleName).entries()) {
assert.ok(name.includes('Workflow'), `live row ${i} keeps the Workflow badge an idle row names`)
assert.ok(name.includes('step'), `live row ${i} keeps the preview an idle row names`)
assert.ok(name.includes('1 running'), `live row ${i} still says how many runs are live`)
}
} finally {
view.unmount()
globalThis.__flybot__ = prev
}
})

// #2230: the two reverse proofs. The counter is still reachable — one live row still names
// its count — and a quiet row still carries no `aria-label`, no digit and no hidden text,
// which is the #2224 contract this change must not reopen.
test('#2230: one live row still names its count, and an idle row carries no aria-label and no hidden text', async () => {
const live = async (count) => {
const graph = { ...delivery(), activeCount: count }
const call = async (name) => {
if (name === 'workflow/state') return { workflows: [graph], templates: [] }
return { ok: true, workflows: [graph] }
}
const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} })
await settle()
return view
}
const busy = await live(3)
try {
const row = findAll(busy.tree, node => node.type === 'button' && String(node.props?.className ?? '').includes('wf-row') && textOf(node).includes('发布交付流水线'))[0]
assert.equal(row.props['aria-label'], undefined, 'the row no longer overrides the name computed from its content')
assert.ok(accessibleName(row).includes('3 running'), 'the count is still part of the name')
assert.equal(findAll(busy.tree, node => node.props?.className === 'wf-sr').length, 1, 'one hidden span carries the count')
} finally {
busy.unmount()
}
const quiet = await live(0)
try {
const row = findAll(quiet.tree, node => node.type === 'button' && String(node.props?.className ?? '').includes('wf-row') && textOf(node).includes('发布交付流水线'))[0]
assert.equal(row.props['aria-label'], undefined, 'an idle row keeps its own text as the name')
assert.equal(/\d/.test(accessibleName(row)), false, 'an idle name carries no digit')
assert.equal(findAll(quiet.tree, node => node.props?.className === 'wf-sr').length, 0, 'an idle row draws no hidden text either')
} finally {
quiet.unmount()
}
})

// #2222 B: opening the list in a read-only window used to write
// `workflow/ingest` once per open card -- a write the reader never asked for,
// and one the host refuses. The Project filter still fills (its read stays).
Expand Down Expand Up @@ -2919,7 +3009,7 @@
// The hint's text is read when the drag is refused, so the host translation is put in place
// after the drawer mounted: a stub standing in for the whole host at mount time is read by the
// page's own reads (avatars, tool links) and hides the workflow list.
globalThis.__flybot__ = { ...(previous ?? {}), t: (key) => (key === 'Tasks' ? '任务' : key) }

Check warning on line 3012 in plugins/workflow/test/client.test.js

View workflow job for this annotation

GitHub Actions / plugins (workflow)

unicorn(no-useless-fallback-in-spread)

Empty fallbacks in spreads are unnecessary
try {
nodeAt('r1').props.onPointerDown({ clientX: 40, clientY: 160, target: { dataset: {} } })
canvas().props.onPointerMove({ clientX: 96, clientY: 208 })
Expand Down
Loading