From 688447323c45121be87824dc352654566e89d4e3 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sun, 15 Nov 2026 16:03:27 +0800 Subject: [PATCH] fix(workflow): print the Workflow badge in the interface language `Workflow` is the word the panel draws beside a lane's name to say what the lane is, not an id anything reads back, so a zh window prints it in Chinese like the `Flow`, `Runs` and `Workflows` words beside it. The plugin's own ZH table mapped it to itself and the sidebar row drew it bare, so both the row and the Hand a task to a Workflow picker painted English under a Chinese page header. The forward i18n guard's example of an identity entry read workflow's own `Workflow` mapping, which goes false the day the entry is translated; the case is now proven on source written for it. The badge is part of the row's accessible name, so docs/subsystems/workflow.md says it now follows the window's language. --- changelog.d/3102-workflow-badge-zh.fixed.md | 1 + core/test/i18n-missing-entries.baseline.json | 1 - .../i18n-plugins-missing-zh.baseline.json | 3 +- core/test/i18n-plugins-missing-zh.test.js | 44 +++++++-------- docs/subsystems/workflow.md | 2 +- .../test/i18n-missing-entries.baseline.json | 1 - .../i18n-plugins-missing-zh.baseline.json | 3 +- .../test/i18n-plugins-missing-zh.test.js | 44 +++++++-------- plugins/workflow/client.js | 4 +- plugins/workflow/test/client.test.js | 54 +++++++++++++++++++ 10 files changed, 106 insertions(+), 51 deletions(-) create mode 100644 changelog.d/3102-workflow-badge-zh.fixed.md diff --git a/changelog.d/3102-workflow-badge-zh.fixed.md b/changelog.d/3102-workflow-badge-zh.fixed.md new file mode 100644 index 000000000..6255c348c --- /dev/null +++ b/changelog.d/3102-workflow-badge-zh.fixed.md @@ -0,0 +1 @@ +The `Workflow` badge on a workflow row now follows the window's language: a Chinese window reads `工作流` in the sidebar row and in the Hand a task to a Workflow target list, instead of the English word standing beside an already-translated page header ([#3102](https://github.com/ThinkFlowLab/FlyBot/issues/3102)). diff --git a/core/test/i18n-missing-entries.baseline.json b/core/test/i18n-missing-entries.baseline.json index c947e0de6..3592e2ca7 100644 --- a/core/test/i18n-missing-entries.baseline.json +++ b/core/test/i18n-missing-entries.baseline.json @@ -163,7 +163,6 @@ "plugins/tree/client.js:archived · merged into {}", "plugins/tree/client.js:outside the tree", "plugins/workflow/client.js:Names the connectors this run Bot may use: github grants mcp__github__*, composio grants the Composio connector. File and shell access is always full, like a normal Bot, and a node that names no connector gets none.", - "plugins/workflow/client.js:Workflow", "plugins/workflow/client.js:这里填这个节点 Bot 能用的连接器:github 对应 mcp__github__*,composio 对应 Composio 连接器。文件与 shell 权限始终与普通 Bot 相同(完全放开);不填连接器时这个节点一个都拿不到。" ] } diff --git a/core/test/i18n-plugins-missing-zh.baseline.json b/core/test/i18n-plugins-missing-zh.baseline.json index 6b961684a..d21767d04 100644 --- a/core/test/i18n-plugins-missing-zh.baseline.json +++ b/core/test/i18n-plugins-missing-zh.baseline.json @@ -2,7 +2,6 @@ "version": 1, "entries": [ "plugins/social/client.js:New group", - "plugins/workflow/client.js:This host does not pass task writes to Workflows, so a card on the board cannot start a run.", - "plugins/workflow/client.js:Workflow" + "plugins/workflow/client.js:This host does not pass task writes to Workflows, so a card on the board cannot start a run." ] } diff --git a/core/test/i18n-plugins-missing-zh.test.js b/core/test/i18n-plugins-missing-zh.test.js index ce92485d5..0ff2133d4 100644 --- a/core/test/i18n-plugins-missing-zh.test.js +++ b/core/test/i18n-plugins-missing-zh.test.js @@ -18,10 +18,12 @@ // from a name or a list written here: a fallback pointing at a different table changes this scan in // the same commit, and a table the scan cannot read fails loudly instead of quietly widening pins. // -// An entry whose value is the key itself is not a translation: `Workflow: 'Workflow'` in -// workflow's table draws `Workflow` in a zh window, so it stays a pin. The contract this scan -// enforces is that no English shows in a zh window, and presence alone would hide exactly the -// entries the guard exists to catch — 69 pins were 3 real ones and 66 false ones. +// An entry whose value is the key itself is not a translation: it draws the English key in a zh +// window, so it stays a pin. The contract this scan enforces is that no English shows in a zh +// window, and presence alone would hide exactly the entries the guard exists to catch — 69 pins +// were 3 real ones and 66 false ones. The case is proven on source written for it, never on a +// plugin file: workflow's own identity entries were translated one by one (P-784's `Workflow`), +// and a test that reads one of them goes false the day the translation lands. // // The sibling scans cover the other two directions — // `core/test/i18n.test.js` the same direction inside core, `core/test/i18n-plugins.test.js` a @@ -204,9 +206,8 @@ export function localDictionaryOf(source) { } // The keys that table really translates: an entry whose value is the key itself renders English, -// so it is not a translation. `Workflow: 'Workflow'` in a plugin's table is the case — a zh window -// shows `Workflow`, and counting it as translated would hide exactly what this guard exists to -// catch. +// so it is not a translation — counting it as translated would hide exactly what this guard +// exists to catch. function translatedByTable(entries) { const translated = new Set() for (const [key, value] of entries) if (value !== key) translated.add(key) @@ -373,24 +374,25 @@ test('a key the plugin\'s own table translates is not a missing entry', () => { }) test('a local entry whose value is the key itself is not a translation', () => { - const source = readFileSync(new URL('../../plugins/workflow/client.js', import.meta.url), 'utf8') + const source = [ + 'const WORDS = {', + " Same: 'Same',", + " Kept: '保留',", + '}', + "const tr = (key) => Object.hasOwn(WORDS, key) ? WORDS[key] : key", + "tr('Same'); tr('Kept')", + ].join('\n') const table = localDictionaryOf(source) - assert.equal(table.get('Workflow'), 'Workflow', "workflow's table maps Workflow to itself") - assert.equal(Object.hasOwn(DICTIONARY.zh, 'Workflow'), false, 'and core has no entry for it') - assert.ok( - forwardMissing(source, 'plugins/workflow/client.js').some((entry) => entry.key === 'Workflow'), - 'so a zh window still draws Workflow, and the scan reports it', - ) - assert.ok( - forwardBaselineOf(scanPlugins()).includes('plugins/workflow/client.js:Workflow'), - 'the pin is the record of that English', - ) - const synthetic = "const WORDS = { Same: 'Same', Kept: '保留' }\ntr('Same'); tr('Kept')" - const tr = "const tr = (key) => Object.hasOwn(WORDS, key) ? WORDS[key] : key" + assert.equal(table.get('Same'), 'Same', 'a table entry may map a key to itself') + assert.notEqual(table.get('Kept'), 'Kept', 'and another entry in the same table may translate') assert.deepEqual( - forwardMissing(`${synthetic}\n${tr}`, 'plugins/sample/client.js').map((entry) => entry.key), + forwardMissing(source, 'plugins/sample/client.js').map((entry) => entry.key), ['Same'], + 'so a zh window still draws Same, and the scan reports it', ) + assert.deepEqual(forwardBaselineOf(forwardMissing(source, 'plugins/sample/client.js')), [ + 'plugins/sample/client.js:Same', + ]) }) test('the local table is the one the fallback names, and a shape it cannot read fails loudly', () => { diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 6a4a1dea9..f7308c649 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -45,7 +45,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; 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). | +| `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). The `Workflow` badge itself is drawn through the plugin's dictionary, so a zh window reads 工作流 in the row and in the Hand a task to a Workflow target list while an English window keeps the word (#3102). 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 diff --git a/patches/ds-bot/files/test/i18n-missing-entries.baseline.json b/patches/ds-bot/files/test/i18n-missing-entries.baseline.json index c947e0de6..3592e2ca7 100644 --- a/patches/ds-bot/files/test/i18n-missing-entries.baseline.json +++ b/patches/ds-bot/files/test/i18n-missing-entries.baseline.json @@ -163,7 +163,6 @@ "plugins/tree/client.js:archived · merged into {}", "plugins/tree/client.js:outside the tree", "plugins/workflow/client.js:Names the connectors this run Bot may use: github grants mcp__github__*, composio grants the Composio connector. File and shell access is always full, like a normal Bot, and a node that names no connector gets none.", - "plugins/workflow/client.js:Workflow", "plugins/workflow/client.js:这里填这个节点 Bot 能用的连接器:github 对应 mcp__github__*,composio 对应 Composio 连接器。文件与 shell 权限始终与普通 Bot 相同(完全放开);不填连接器时这个节点一个都拿不到。" ] } diff --git a/patches/ds-bot/files/test/i18n-plugins-missing-zh.baseline.json b/patches/ds-bot/files/test/i18n-plugins-missing-zh.baseline.json index 6b961684a..d21767d04 100644 --- a/patches/ds-bot/files/test/i18n-plugins-missing-zh.baseline.json +++ b/patches/ds-bot/files/test/i18n-plugins-missing-zh.baseline.json @@ -2,7 +2,6 @@ "version": 1, "entries": [ "plugins/social/client.js:New group", - "plugins/workflow/client.js:This host does not pass task writes to Workflows, so a card on the board cannot start a run.", - "plugins/workflow/client.js:Workflow" + "plugins/workflow/client.js:This host does not pass task writes to Workflows, so a card on the board cannot start a run." ] } diff --git a/patches/ds-bot/files/test/i18n-plugins-missing-zh.test.js b/patches/ds-bot/files/test/i18n-plugins-missing-zh.test.js index ce92485d5..0ff2133d4 100644 --- a/patches/ds-bot/files/test/i18n-plugins-missing-zh.test.js +++ b/patches/ds-bot/files/test/i18n-plugins-missing-zh.test.js @@ -18,10 +18,12 @@ // from a name or a list written here: a fallback pointing at a different table changes this scan in // the same commit, and a table the scan cannot read fails loudly instead of quietly widening pins. // -// An entry whose value is the key itself is not a translation: `Workflow: 'Workflow'` in -// workflow's table draws `Workflow` in a zh window, so it stays a pin. The contract this scan -// enforces is that no English shows in a zh window, and presence alone would hide exactly the -// entries the guard exists to catch — 69 pins were 3 real ones and 66 false ones. +// An entry whose value is the key itself is not a translation: it draws the English key in a zh +// window, so it stays a pin. The contract this scan enforces is that no English shows in a zh +// window, and presence alone would hide exactly the entries the guard exists to catch — 69 pins +// were 3 real ones and 66 false ones. The case is proven on source written for it, never on a +// plugin file: workflow's own identity entries were translated one by one (P-784's `Workflow`), +// and a test that reads one of them goes false the day the translation lands. // // The sibling scans cover the other two directions — // `core/test/i18n.test.js` the same direction inside core, `core/test/i18n-plugins.test.js` a @@ -204,9 +206,8 @@ export function localDictionaryOf(source) { } // The keys that table really translates: an entry whose value is the key itself renders English, -// so it is not a translation. `Workflow: 'Workflow'` in a plugin's table is the case — a zh window -// shows `Workflow`, and counting it as translated would hide exactly what this guard exists to -// catch. +// so it is not a translation — counting it as translated would hide exactly what this guard +// exists to catch. function translatedByTable(entries) { const translated = new Set() for (const [key, value] of entries) if (value !== key) translated.add(key) @@ -373,24 +374,25 @@ test('a key the plugin\'s own table translates is not a missing entry', () => { }) test('a local entry whose value is the key itself is not a translation', () => { - const source = readFileSync(new URL('../../plugins/workflow/client.js', import.meta.url), 'utf8') + const source = [ + 'const WORDS = {', + " Same: 'Same',", + " Kept: '保留',", + '}', + "const tr = (key) => Object.hasOwn(WORDS, key) ? WORDS[key] : key", + "tr('Same'); tr('Kept')", + ].join('\n') const table = localDictionaryOf(source) - assert.equal(table.get('Workflow'), 'Workflow', "workflow's table maps Workflow to itself") - assert.equal(Object.hasOwn(DICTIONARY.zh, 'Workflow'), false, 'and core has no entry for it') - assert.ok( - forwardMissing(source, 'plugins/workflow/client.js').some((entry) => entry.key === 'Workflow'), - 'so a zh window still draws Workflow, and the scan reports it', - ) - assert.ok( - forwardBaselineOf(scanPlugins()).includes('plugins/workflow/client.js:Workflow'), - 'the pin is the record of that English', - ) - const synthetic = "const WORDS = { Same: 'Same', Kept: '保留' }\ntr('Same'); tr('Kept')" - const tr = "const tr = (key) => Object.hasOwn(WORDS, key) ? WORDS[key] : key" + assert.equal(table.get('Same'), 'Same', 'a table entry may map a key to itself') + assert.notEqual(table.get('Kept'), 'Kept', 'and another entry in the same table may translate') assert.deepEqual( - forwardMissing(`${synthetic}\n${tr}`, 'plugins/sample/client.js').map((entry) => entry.key), + forwardMissing(source, 'plugins/sample/client.js').map((entry) => entry.key), ['Same'], + 'so a zh window still draws Same, and the scan reports it', ) + assert.deepEqual(forwardBaselineOf(forwardMissing(source, 'plugins/sample/client.js')), [ + 'plugins/sample/client.js:Same', + ]) }) test('the local table is the one the fallback names, and a shape it cannot read fails loudly', () => { diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index 9f2e7ec3b..4d3cfae15 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -35,7 +35,7 @@ window.__ModuleLoader__.load({ Cancel: '取消', Assign: '确认分配', 'Assign to {target}': '确认分配给 {target}', - Workflow: 'Workflow', + Workflow: '工作流', 'One Bot (as before)': '单个 Bot(原有方式)', 'Concurrent runs': '同时运行', '{count} running · queue {depth}': '{count} 个在运行 · 队列 {depth}', @@ -2362,7 +2362,7 @@ window.__ModuleLoader__.load({ }, h('span', { className: 'wf-ico' }, h(MiniFlowchart, { workflow: item, compact: true })), h('span', { className: 'wf-txt' }, - h('span', { className: 'wf-t1' }, item.name, h('span', { className: 'wf-badge' }, 'Workflow'), + h('span', { className: 'wf-t1' }, item.name, h('span', { className: 'wf-badge' }, tr('Workflow')), h('span', { className: `wf-proj${projectName(item.projectId) ? '' : ' none'}` }, projectName(item.projectId) || tr('Unassigned'))), h('span', { className: 'wf-t2' }, sidebarPreview(item))), (item.activeCount ?? 0) > 0 ? h('span', { className: 'wf-live-wrap', 'aria-hidden': true }, diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index ffc68d9fc..cd5e17141 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -1748,6 +1748,60 @@ test('Chinese labels follow the host Tasks translation', () => { } }) +// P-784: `Workflow` is the word the panel prints beside a lane's name to say what the lane is, +// not an id anything reads back, so a zh window prints it in Chinese like the `Flow`, `Runs` and +// `Workflows` beside it. Two places draw it — the sidebar row and the target list of Hand a task +// to a Workflow — and neither can lean on the core dictionary, which has no `Workflow` entry, so +// both read the plugin's own ZH table. The host below answers a zh UI the way core's `t` does: +// it translates what it knows and hands an unknown key back as itself, so Chinese here can only +// come from the plugin's table. +const zhUi = { t: key => (key === 'Tasks' ? '任务' : key), host: async () => [] } +const badgesOf = tree => findAll(tree, node => String(node.props?.className ?? '').split(' ').includes('wf-badge')) + +test('P-784: a zh window prints the sidebar row\'s Workflow badge in Chinese', async () => { + const graph = delivery() + const call = async (name) => { + if (name === 'workflow/state') return { workflows: [graph], templates: [] } + return { ok: true, workflows: [graph] } + } + const prev = globalThis.__flybot__ + globalThis.__flybot__ = zhUi + try { + const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + await settle() + const badges = badgesOf(view.tree) + assert.equal(badges.length, 1, 'the sidebar row draws one badge') + assert.equal(textOf(badges[0]), '工作流', `a zh window reads the badge in Chinese, got ${JSON.stringify(textOf(badges[0]))}`) + view.unmount() + } finally { + globalThis.__flybot__ = prev + } +}) + +test('P-784: the same badge in Hand a task to a Workflow reads Chinese too', async () => { + const graph = delivery() + const call = async (name) => { + if (name === 'workflow/state') return { workflows: [graph], templates: [] } + return { ok: true, workflows: [graph] } + } + const prev = globalThis.__flybot__ + globalThis.__flybot__ = zhUi + 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 targets = findAll(view.tree, node => String(node.props?.className ?? '').split(' ').includes('wf-tgt')) + assert.equal(targets.length, 1, 'the picker offers one lane') + const badges = badgesOf(targets[0]) + assert.equal(badges.length, 1, 'the lane target draws one badge') + assert.equal(textOf(badges[0]), '工作流', `a zh window reads the badge in Chinese, got ${JSON.stringify(textOf(badges[0]))}`) + view.unmount() + } finally { + globalThis.__flybot__ = prev + } +}) + test('a failed stage graph save is reported, not swallowed', async (t) => { await withUnhandled(async (unhandled) => { const graph = delivery()