diff --git a/changelog.d/2524-workflows-duplicate-sentence.fixed.md b/changelog.d/2524-workflows-duplicate-sentence.fixed.md new file mode 100644 index 000000000..98e258849 --- /dev/null +++ b/changelog.d/2524-workflows-duplicate-sentence.fixed.md @@ -0,0 +1 @@ +In a read-only window, **Settings → Workflows** no longer prints the host's read-only sentence twice: with a workflow graph open, the graph's edit bar drew a second copy of the sentence the Settings header already carries, under its own `role="status"`, so the same words sat on one screen twice and a screen reader announced them twice. The edit bar now shows **Back** only, the header's **Reload** stays the way out, and a writable window keeps **Edit** exactly as it was ([#2524](https://github.com/ThinkFlowLab/FlyBot/issues/2524)). diff --git a/core/client.js b/core/client.js index 0302a97ec..1ab370e6d 100644 --- a/core/client.js +++ b/core/client.js @@ -17295,7 +17295,7 @@ function WorkflowsView({ phase = "loading", pack = null, error = "", opened = nu { className: "bt-wf-actions" }, (0, import_react37.createElement)("button", { type: "button", onClick: () => onBack?.() }, t("Back")), showUse ? (0, import_react37.createElement)("button", { type: "button", onClick: () => onUse?.(opened.source === "factory" ? opened.id : opened.fromFactory) }, t("Use this")) : null, - locked ? (0, import_react37.createElement)("span", { className: "bt-wf-status", role: "status" }, hostText(readOnly)) : opened?.source === "own" ? (0, import_react37.createElement)("button", { type: "button", onClick: () => onEditing?.(!editing) }, editing ? t("Finish editing") : t("Edit")) : null + locked ? null : opened?.source === "own" ? (0, import_react37.createElement)("button", { type: "button", onClick: () => onEditing?.(!editing) }, editing ? t("Finish editing") : t("Edit")) : null ), (0, import_react37.createElement)("h3", { className: "bt-wf-title" }, graph.name), graph.description ? (0, import_react37.createElement)("p", { className: "bt-wf-hint" }, graph.description) : null, diff --git a/core/src/client/workflows.js b/core/src/client/workflows.js index 63a310596..916de211b 100644 --- a/core/src/client/workflows.js +++ b/core/src/client/workflows.js @@ -3,7 +3,7 @@ // sidebar run uses. A read always settles: a host that never answers becomes // an error, an empty store says so, and the file path is not the page. import { createElement as h, useEffect, useRef, useState } from 'react' -import { hostText, t } from './i18n.js' +import { t } from './i18n.js' export const READ_MS = 8000 @@ -562,11 +562,12 @@ export function WorkflowsView({ phase = 'loading', pack = null, error = '', open const templates = pack?.templates ?? [] const templateIds = new Set(templates.map((item) => item.id)) const yours = (pack?.workflows ?? []).filter((item) => !templateIds.has(item.id)) - // #2176: a window that holds the team read-only carries the host's own sentence. The page - // shows that sentence where Edit was and never opens the editor, so `workflow/graph` is not - // offered in the first place instead of being refused after the layout was built. `Use this` - // writes `workflow/create`, which the channel's READS list does not hold either, so a - // read-only window does not offer that one either. + // #2176: a window that holds the team read-only never opens the editor, so `workflow/graph` + // is not offered in the first place instead of being refused after the layout was built. + // `Use this` writes `workflow/create`, which the channel's READS list does not hold either, + // so a read-only window does not offer that one either. The host's standing sentence stays + // in the Settings header, once per page: this edit bar drew a second copy of it here, and a + // second live region is read out twice. const readOnly = typeof roster?.readOnly === 'string' ? roster.readOnly.trim() : '' const locked = readOnly !== '' const showUse = !locked && (opened?.source === 'factory' || opened?.fromFactory !== undefined) @@ -600,7 +601,7 @@ export function WorkflowsView({ phase = 'loading', pack = null, error = '', open h('button', { type: 'button', onClick: () => onBack?.() }, t('Back')), showUse ? h('button', { type: 'button', onClick: () => onUse?.(opened.source === 'factory' ? opened.id : opened.fromFactory) }, t('Use this')) : null, locked - ? h('span', { className: 'bt-wf-status', role: 'status' }, hostText(readOnly)) + ? null : opened?.source === 'own' ? h('button', { type: 'button', onClick: () => onEditing?.(!editing) }, editing ? t('Finish editing') : t('Edit')) : null), h('h3', { className: 'bt-wf-title' }, graph.name), graph.description ? h('p', { className: 'bt-wf-hint' }, graph.description) : null, diff --git a/core/test/workflows-page.test.js b/core/test/workflows-page.test.js index e2e04de39..024e29a33 100644 --- a/core/test/workflows-page.test.js +++ b/core/test/workflows-page.test.js @@ -1389,13 +1389,15 @@ test('a forward edge into the next row turns above the tallest CJK named card (# // the layout — and then the state is discarded with one technical line. const READ_ONLY = 'another dsh holds the team, so this window is read-only' -test('#2176: a read-only window renders the host sentence instead of Edit and offers no rename or delete', () => { +// The host's standing sentence belongs to the Settings header, once per page: this edit bar +// used to draw a second copy of it where Edit would be, and a second live region is read out +// twice. The header keeps the sentence and its one Reload; the graph keeps Back. +test('#2176: a read-only graph draws no editing control and leaves the host sentence to the header', () => { const view = renderView({ phase: 'ready', pack, opened: { source: 'own', id: 'wf-mine' }, error: '', roster: { bots: [], readOnly: READ_ONLY } }) const editingControl = all(view).filter((entry) => entry.type === 'button' && (text(entry) === 'Edit' || text(entry) === 'Finish editing')) assert.deepEqual(editingControl.map(text), [], 'a read-only window offers no editing control') - const sentence = all(view).find((entry) => entry.props?.className === 'bt-wf-status' && text(entry) === READ_ONLY) - assert.ok(sentence, 'the host sentence is rendered on the page') - assert.equal(text(sentence), READ_ONLY, 'the page repeats the host wording, it does not invent one') + assert.equal(all(view).some((entry) => text(entry) === READ_ONLY), false, 'the edit bar adds no second copy of the host sentence') + assert.equal(all(view).some((entry) => entry.props?.role === 'status' && text(entry) === READ_ONLY), false, 'and no second live region carrying it') assert.equal(all(view).some((entry) => entry.props?.['data-port']), false, 'no port handles to drag') // The read side is untouched: the graph, its cards and the run chips still render. assert.ok(all(view).some((entry) => entry.props?.['data-node'] === 'coder'), 'the opened graph still renders') diff --git a/core/test/workflows-readonly-once.test.js b/core/test/workflows-readonly-once.test.js new file mode 100644 index 000000000..95e9c108b --- /dev/null +++ b/core/test/workflows-readonly-once.test.js @@ -0,0 +1,159 @@ +// The standing read-only sentence belongs to the dialog header, once per page +// (settings-readonly-pages.test.js). That page list never opened a workflow, so it could not +// see the second copy: in a window that holds the team read-only, Settings → Workflows drew +// the host's sentence in the header and again in the graph's edit bar, a second live region +// read out twice. The edit bar now draws Back only — the header's Reload is the whole +// screen's way out — and a writable window keeps Edit where it was. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const { outputFiles } = await build({ + stdin: { + contents: [ + "export { SettingsDialog } from './src/client/settings.js'", + "export { WorkflowsView } from './src/client/workflows.js'", + ].join('\n'), + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) + +const h = (type, props, ...children) => ({ type, props: { ...props, children: children.flat(Infinity) } }) + +// One render pass: the dialog and the graph canvas both call hooks, and nothing here +// re-renders, so each component is drawn with a fresh set of slots. +let slots = [] +let index = 0 +const hook = () => { const at = index++; return at } +const react = { + createElement: h, Component: class {}, Fragment: Symbol('F'), + useMemo: (fn) => { const at = hook(); if (!(at in slots)) slots[at] = fn(); return slots[at] }, + useState: (initial) => { + const at = hook() + if (!(at in slots)) slots[at] = typeof initial === 'function' ? initial() : initial + return [slots[at], (value) => { slots[at] = typeof value === 'function' ? value(slots[at]) : value }] + }, + useCallback: (fn) => { const at = hook(); if (!(at in slots)) slots[at] = fn; return slots[at] }, + useRef: (value) => { const at = hook(); if (!(at in slots)) slots[at] = { current: value }; return slots[at] }, + useEffect: (fn) => { const at = hook(); if (!(at in slots)) slots[at] = fn }, + useLayoutEffect: () => { hook() }, + useId: () => { hook(); return 'id' }, + useSyncExternalStore: (_subscribe, get) => { hook(); return get() }, +} +const win = { location: { hash: '' }, addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) } +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, console, setTimeout, clearTimeout, + requestAnimationFrame: () => 0, cancelAnimationFrame() {}, + document: { hidden: false, addEventListener() {}, removeEventListener() {}, createElement: () => ({ getContext: () => ({}) }) }, + navigator: { language: 'en' }, + localStorage: { getItem: () => null, setItem() {} }, + window: win, + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return {} + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { SettingsDialog, WorkflowsView } = module.exports + +const SENTENCE = 'The Bot team in /home/xuke/.flybot is open in another dsh (Web or Desktop, pid 41 on host). Close the other one and reload.' + +const chief = { id: 'chief', name: 'Chief', role: 'Researcher', model: 'mock/m1', workDir: '/work/chief', instructions: 'Run the team.', pinned: true } +const baseRoster = { + bots: [chief], byId: { chief }, rooms: [], roomsById: {}, mainBotIds: ['chief'], mainBotId: 'chief', + prefs: { theme: 'deepseek', motion: 'quiet', notifications: true }, + models: [], connectors: [], capabilities: [], secrets: [], humans: [], remoteBots: [], memory: true, + questions: {}, exchangeTurns: {}, +} +const lockedRoster = { ...baseRoster, readOnly: SENTENCE } + +const lane = { + id: 'w1', + name: 'Hotfix lane', + description: 'A short lane.', + chain: 'Planner → Coder', + nodes: [ + { id: 'planner', name: 'Planner', role: 'Planner', x: 0, y: 0 }, + { id: 'coder', name: 'Coder', role: 'Coder', x: 0, y: 108 }, + ], + edges: [{ from: 'planner', to: 'coder' }], + layers: [['planner'], ['coder']], +} +const pack = { ok: true, templates: [], workflows: [lane] } + +// Every action a locked page could reach; a press that lands in one is a write. +const actions = () => new Proxy({ openSettings() {}, closeSettings() {}, openSession() {}, notify() {}, showDetails() {} }, { + get: (target, name) => (name in target ? target[name] : () => {}), +}) + +const text = (node) => { + if (Array.isArray(node)) return node.map(text).join('') + if (typeof node === 'string') return node + if (node && typeof node === 'object') return text(node.props?.children) + return '' +} + +// Draw the tree the way the screen would: a function component is called, a host element is +// kept. One pass is enough — the test asks what the first screen carries. +const draw = (node, out = [], depth = 0) => { + if (Array.isArray(node)) { for (const item of node) draw(item, out, depth); return out } + if (!node || typeof node !== 'object') return out + if (typeof node.type === 'function' && depth < 10) { + const saved = slots + const savedIndex = index + slots = [] + index = 0 + let rendered + try { rendered = node.type({ ...node.props }) } catch { out.push(node); return out } finally { slots = saved; index = savedIndex } + return draw(rendered, out, depth + 1) + } + out.push(node) + draw(node.props?.children, out, depth) + return out +} + +// The sentence as a reader meets it: one text block per drawn copy, whatever element and +// class carry it. A wrapper and the node inside it are one copy, not two. +const sentences = (tree) => draw(tree).filter(node => text(node) === SENTENCE + && !draw(node.props?.children).some(child => text(child) === SENTENCE)) + +const view = (over = {}) => h(WorkflowsView, { phase: 'ready', pack, opened: { source: 'own', id: 'w1' }, error: '', roster: lockedRoster, ...over }) +const dialog = (roster) => SettingsDialog({ page: 'workflows', id: null, roster, actions: actions(), useUpdate: () => ({}) }) +const buttons = (tree) => draw(tree).filter(node => node.type === 'button') + +test('a read-only window draws the host sentence once, not twice, with a workflow graph open', () => { + const header = sentences(dialog(lockedRoster)) + assert.equal(header.length, 1, 'the dialog header keeps the standing sentence') + assert.equal(header[0].props.role, 'status', 'the header sentence stays the live region') + assert.equal(text(header[0]), SENTENCE, "the header keeps the host's own wording") + + const graph = sentences(view()) + assert.equal(graph.length, 0, 'the open graph does not repeat the sentence the header already carries') + assert.equal(header.length + graph.length, 1, 'the screen carries the host sentence once') + assert.equal(draw(view()).some(node => node.props?.role === 'status' && text(node) === SENTENCE), false, + 'the edit bar adds no second live region') +}) + +test('the read-only graph keeps Back and adds no second Reload, Edit or Use this', () => { + const labels = buttons(view()).map(text) + assert.deepEqual(labels, ['Back'], 'Back is the only control the edit bar draws') + assert.equal(labels.includes('Reload'), false, "the header's Reload is the screen's one Reload") + assert.equal(labels.includes('Edit') || labels.includes('Finish editing'), false, 'the graph offers no editing control') + assert.equal(labels.includes('Use this'), false, 'the graph offers no copy of a preset') +}) + +test('a writable window keeps the graph editor and draws no read-only sentence', () => { + assert.equal(sentences(dialog(baseRoster)).length, 0, 'a writable header draws no sentence') + const tree = view({ roster: baseRoster }) + assert.equal(sentences(tree).length, 0, 'a writable graph draws no sentence') + const labels = buttons(tree).map(text) + assert.ok(labels.includes('Edit'), 'Edit stays in a writable window') + assert.equal(labels.includes('Reload'), false, 'a writable window draws no Reload') + assert.ok(draw(tree).some(node => node.props?.['data-node'] === 'coder'), 'the opened graph still renders') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 2bb2c5798..c38df9693 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -219,7 +219,7 @@ The member's own chat keeps its `busyAcks[sessionId]` bubble and its visibility - The + menu is a popover anchored next to the sidebar **+** (#195, #173): its `.bt-pane` carries `data-menu` and stays see-through, the menu is a 280px card at the top left with no title row, and a press on the pane outside the card (or Escape) closes it. New chat, New group chat, New Bot and plugin rows open their full pane as before. - Settings → **Workflows** flowchart wire plane (#1459): `GraphCanvas` in `workflows.js` places nodes on a zero-width center line (`.bt-wf-plane`). Connector paths are drawn in an SVG shifted by `WIRE_SPAN` (480): the plane sets `--bt-wf-wire-span` on its inline style and `.bt-wf-wires` in `styles.js` uses `left:calc(-1 * var(--bt-wf-wire-span))` and `width:calc(2 * var(--bt-wf-wire-span))`, so coordinates `node.x + WIRE_SPAN` line up with each card's center instead of fanning to a corner. The `@flybot/workflow` stage drawer uses the same anchoring (`--wf-wire-span` on `.wf-plane`, matching rules on `.wf-wires`). Since #2122 a wire is routed rather than drawn as one symmetric cubic: `wireRoutes(nodes, edges, heights)` gives every forward edge a port-to-port dogleg whose vertical run sits on a lane no other wire holds (a lane at least `WIRE_LANE` from every overlapping one, never inside a card and `WIRE_GAP` clear of one, so two cards on one column send the wire round the card between them), a forward edge whose two cards share a row — a loop across one layer, or a skip across a row — has no corridor to run in, so its two horizontal runs sit `WIRE_GAP` outside that row instead (below its lowest measured bottom, above the target's top) and the vertical run between them has to clear both end cards' bodies as well as the cards beside them (#2151), an edge that leaves a row for one below measures that corridor from the row's lowest measured bottom too, so a taller card beside the source does not swallow the run (#2160), and the turn is clamped to `to.top − 1`, so a card beside the source that reaches past the next row's top — at the factory 108px pitch a 40-character CJK name measures 111px against the 108px pitch — leaves the run above that top instead of inside the row below, and a 91px latin card still turns inside the corridor that is left (#2164), a back edge (its target above its source) leaves and lands on the right edges and loops outside every card at `rightMost + WIRE_GAP`, and each of its two turns runs in the gap just outside its own card's row — stepped further out when a card's measured height leaves less than `WIRE_GAP` — so a back edge that shares a row with a parallel card clears that card instead of passing behind it, and two edges that must share a port cross at an angle instead of lying on one stroke. The SVG group carries the `WIRE_SPAN` shift, so a route's `d` stays in plane coordinates. Card heights are measured the way the width is (`ResizeObserver` on the cards, seeded through the `cardHeights` prop), and `cardBoxes`/`parallelFrames` read them: the parallel frame is the union of its members' measured boxes plus `FRAME_PAD` on all four sides, so it encloses a member the editor stored lower and a taller run card, with the authored 72px only the floor before a card is measured. A Settings graph whose authored 960px plane is wider than the canvas is fitted instead of clipped (#1752): `fitScale(nodes, width)` returns one `scale()` from the widest content reach (`|x| + 108`, so the dashed frame counts), capped at 1 and floored at 0.5 when the canvas is too narrow for half size; `GraphCanvas` measures the canvas (`getBoundingClientRect` plus a `ResizeObserver`) and wraps the plane in `.bt-wf-fit`, so cards, frames and the wire SVG share the transform. pointer deltas and drop offsets are divided by the scale, so dragging still commits authored plane coordinates. Below the 0.5 floor the plane is wider than the canvas by design, and a scroller cannot go left of `scrollLeft` 0, so #1769 reserves the leftover on the left instead of letting it spill out of both edges: `fitLayout(nodes, width)` returns `{ reach, scale, pad, left, right, scrollWidth }` from the same reach, `GraphCanvas` puts `pad` on the stage's `padding-left`, everything then sits at `x >= 0` and only the right side scrolls. A graph that fits returns `pad` 0 and keeps the #1752 scale unchanged; the width can be seeded through the `canvasWidth` prop before the first measure, and the measurement still wins. Tests: `plugins/workflow/test/client.test.js` (the plugin's own wire plane); Since P-395 each route is also a hit target of its own: a 10px transparent path (inline `pointer-events: stroke`, so it reaches the pointer through the `pointer-events: none` layer) carries `data-key`, `role="img"` and an `aria-label` plus `` naming both cards (`A → B`), so two wires that end at the same port can still be told apart and read. The plane is no longer `aria-hidden`; it is a named group (`Connections`). While the pointer is on one wire that route carries `data-active` (`styles.js` draws its stroke in `--bt-accent` at 2.4px) and every other route carries `data-dim` (opacity .16), cleared when the pointer leaves. That is pointer state only: in a read-only graph, and in Edit, hovering or clicking a wire commits nothing. Settings: `core/test/workflows-page.test.js` (including the #2122 routed fork/join/back-edge case). -- Settings → **Workflows** (`src/client/workflows.js`, #1364) sits right after **Scheduled tasks**, beside the other automation page, so the page list's existing order and the plugin-slot rule (a `flybot.settings.page` entry is listed after Connectors) are unchanged. It is not a second row in the account menu: the menu keeps its one name per destination. The page reads `@flybot/workflow` through `call('workflow/state')`: **Factory presets** are the template catalog (a drawable graph, so a pipeline can be opened before it is copied) and **Yours** are the workflows saved on this machine whose ids are not those templates. Each user-owned workflow row shows rename (✎) and delete (×) action buttons; factory presets show no actions and refuse both operations (#1434). Rename opens a modal with a name field and calls `workflow/rename`; delete confirms ("Are you sure you want to delete '{name}'? This cannot be undone.") and calls `workflow/delete`, which removes the workflow from state, releases ephemeral Bots if a run exists, and cleans logs/runs/routes. Opening one draws the flowchart read-only: a saved workflow gets **Edit** (`WorkflowsView` in `src/client/workflows.js`), and only then can a node be moved, an edge drawn, a local Bot dropped, a node removed, and each node's **system prompt** typed in a field beside the canvas — that prompt is what the node's run Bot receives — and the picture is saved with `workflow/graph`; **Finish editing** leaves that mode (#1393, #1378). A factory preset stays read-only until **Use this** copies it with `workflow/create`, and that copy opens in Edit. While any workflow of the page has `activeCount > 0` the page reads `workflow/state` again every 2 s — the room drawer's own predicate and beat — so a chip that goes running → done moves without reopening the page and a finished run stops the beat; an **Edit** in progress suspends it and unmount clears it (#1810). A read that rejects or never returns settles as an error with **Retry**; an empty store says there are no workflows. When that read is `bot/not-found`, or the host says `Unknown FlyBot endpoint workflow/…`, the page says the Workflow plugin is not installed or not enabled (a default install includes `@flybot/workflow`) in the interface language, and **Retry** stays (#1381). Any other failure still shows its own reason. The page does not teach `workflows/<id>.json`. The definition engine that still stores those files stays on the host and is not this page. In a window that holds the team read-only, `roster.readOnly` carries the host's own sentence: the page draws that sentence where **Edit** was, shows no rename/delete on **Yours**, and never opens the editor, so `workflow/graph` is not called at all — the channel would refuse it after the layout was built (#2176). Tests: `core/test/workflows-page.test.js`. +- Settings → **Workflows** (`src/client/workflows.js`, #1364) sits right after **Scheduled tasks**, beside the other automation page, so the page list's existing order and the plugin-slot rule (a `flybot.settings.page` entry is listed after Connectors) are unchanged. It is not a second row in the account menu: the menu keeps its one name per destination. The page reads `@flybot/workflow` through `call('workflow/state')`: **Factory presets** are the template catalog (a drawable graph, so a pipeline can be opened before it is copied) and **Yours** are the workflows saved on this machine whose ids are not those templates. Each user-owned workflow row shows rename (✎) and delete (×) action buttons; factory presets show no actions and refuse both operations (#1434). Rename opens a modal with a name field and calls `workflow/rename`; delete confirms ("Are you sure you want to delete '{name}'? This cannot be undone.") and calls `workflow/delete`, which removes the workflow from state, releases ephemeral Bots if a run exists, and cleans logs/runs/routes. Opening one draws the flowchart read-only: a saved workflow gets **Edit** (`WorkflowsView` in `src/client/workflows.js`), and only then can a node be moved, an edge drawn, a local Bot dropped, a node removed, and each node's **system prompt** typed in a field beside the canvas — that prompt is what the node's run Bot receives — and the picture is saved with `workflow/graph`; **Finish editing** leaves that mode (#1393, #1378). A factory preset stays read-only until **Use this** copies it with `workflow/create`, and that copy opens in Edit. While any workflow of the page has `activeCount > 0` the page reads `workflow/state` again every 2 s — the room drawer's own predicate and beat — so a chip that goes running → done moves without reopening the page and a finished run stops the beat; an **Edit** in progress suspends it and unmount clears it (#1810). A read that rejects or never returns settles as an error with **Retry**; an empty store says there are no workflows. When that read is `bot/not-found`, or the host says `Unknown FlyBot endpoint workflow/…`, the page says the Workflow plugin is not installed or not enabled (a default install includes `@flybot/workflow`) in the interface language, and **Retry** stays (#1381). Any other failure still shows its own reason. The page does not teach `workflows/<id>.json`. The definition engine that still stores those files stays on the host and is not this page. In a window that holds the team read-only, `roster.readOnly` carries the host's own sentence, and the page draws none of its own: the Settings header prints it once per page (#2202), so the graph's edit bar shows **Back** only instead of a second copy of it — a second live region would be read out twice — the page shows no rename/delete on **Yours**, and it never opens the editor, so `workflow/graph` is not called at all — the channel would refuse it after the layout was built (#2176). Tests: `core/test/workflows-page.test.js`. - The sidebar footer button (`ConnectApps` in `sidebar.js`, label **Connect apps** / 连接应用) opens Settings > Connectors through `actions.openConnectors()`, the same call and the same words as the account menu's **Connect apps** row. The shell's plugin panel stays reachable in the Bot surface through the account menu's **Plugins** row (`actions.selectPanel('plugins')`, 插件), without switching to classic Agent mode. - One name per destination in the account menu and command palette (#392, #393, #394): **Settings** opens the Settings dialog (General), since **Bot settings** / **Edit Bot** open one Bot; **Scheduled tasks** opens Settings → Scheduled tasks, the name Settings and a Bot's details use (DSH's own bundle stays "Automation tasks" in the Plugins hints); and **Switch to classic Agent mode** is the only way to classic Agent mode, so Settings → General is Appearance only. - Create Bot starts on the Main Bot's model (#230): `startModel()` (`bot-draft.js`) picks the Main Bot's chosen model, else the one it last ran on, when it is served; then `roster.defaultModel`, then the first served model. Only served models are picked, so a provider without a credential is never preselected. diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index f379ff6bf..a198a111d 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -21839,7 +21839,7 @@ diff --git a/client.js b/client.js + { className: "bt-wf-actions" }, + (0, import_react37.createElement)("button", { type: "button", onClick: () => onBack?.() }, t("Back")), + showUse ? (0, import_react37.createElement)("button", { type: "button", onClick: () => onUse?.(opened.source === "factory" ? opened.id : opened.fromFactory) }, t("Use this")) : null, -+ locked ? (0, import_react37.createElement)("span", { className: "bt-wf-status", role: "status" }, hostText(readOnly)) : opened?.source === "own" ? (0, import_react37.createElement)("button", { type: "button", onClick: () => onEditing?.(!editing) }, editing ? t("Finish editing") : t("Edit")) : null ++ locked ? null : opened?.source === "own" ? (0, import_react37.createElement)("button", { type: "button", onClick: () => onEditing?.(!editing) }, editing ? t("Finish editing") : t("Edit")) : null + ), + (0, import_react37.createElement)("h3", { className: "bt-wf-title" }, graph.name), + graph.description ? (0, import_react37.createElement)("p", { className: "bt-wf-hint" }, graph.description) : null, diff --git a/patches/ds-bot/files/src/client/workflows.js b/patches/ds-bot/files/src/client/workflows.js index 63a310596..916de211b 100644 --- a/patches/ds-bot/files/src/client/workflows.js +++ b/patches/ds-bot/files/src/client/workflows.js @@ -3,7 +3,7 @@ // sidebar run uses. A read always settles: a host that never answers becomes // an error, an empty store says so, and the file path is not the page. import { createElement as h, useEffect, useRef, useState } from 'react' -import { hostText, t } from './i18n.js' +import { t } from './i18n.js' export const READ_MS = 8000 @@ -562,11 +562,12 @@ export function WorkflowsView({ phase = 'loading', pack = null, error = '', open const templates = pack?.templates ?? [] const templateIds = new Set(templates.map((item) => item.id)) const yours = (pack?.workflows ?? []).filter((item) => !templateIds.has(item.id)) - // #2176: a window that holds the team read-only carries the host's own sentence. The page - // shows that sentence where Edit was and never opens the editor, so `workflow/graph` is not - // offered in the first place instead of being refused after the layout was built. `Use this` - // writes `workflow/create`, which the channel's READS list does not hold either, so a - // read-only window does not offer that one either. + // #2176: a window that holds the team read-only never opens the editor, so `workflow/graph` + // is not offered in the first place instead of being refused after the layout was built. + // `Use this` writes `workflow/create`, which the channel's READS list does not hold either, + // so a read-only window does not offer that one either. The host's standing sentence stays + // in the Settings header, once per page: this edit bar drew a second copy of it here, and a + // second live region is read out twice. const readOnly = typeof roster?.readOnly === 'string' ? roster.readOnly.trim() : '' const locked = readOnly !== '' const showUse = !locked && (opened?.source === 'factory' || opened?.fromFactory !== undefined) @@ -600,7 +601,7 @@ export function WorkflowsView({ phase = 'loading', pack = null, error = '', open h('button', { type: 'button', onClick: () => onBack?.() }, t('Back')), showUse ? h('button', { type: 'button', onClick: () => onUse?.(opened.source === 'factory' ? opened.id : opened.fromFactory) }, t('Use this')) : null, locked - ? h('span', { className: 'bt-wf-status', role: 'status' }, hostText(readOnly)) + ? null : opened?.source === 'own' ? h('button', { type: 'button', onClick: () => onEditing?.(!editing) }, editing ? t('Finish editing') : t('Edit')) : null), h('h3', { className: 'bt-wf-title' }, graph.name), graph.description ? h('p', { className: 'bt-wf-hint' }, graph.description) : null, diff --git a/patches/ds-bot/files/test/workflows-page.test.js b/patches/ds-bot/files/test/workflows-page.test.js index e2e04de39..024e29a33 100644 --- a/patches/ds-bot/files/test/workflows-page.test.js +++ b/patches/ds-bot/files/test/workflows-page.test.js @@ -1389,13 +1389,15 @@ test('a forward edge into the next row turns above the tallest CJK named card (# // the layout — and then the state is discarded with one technical line. const READ_ONLY = 'another dsh holds the team, so this window is read-only' -test('#2176: a read-only window renders the host sentence instead of Edit and offers no rename or delete', () => { +// The host's standing sentence belongs to the Settings header, once per page: this edit bar +// used to draw a second copy of it where Edit would be, and a second live region is read out +// twice. The header keeps the sentence and its one Reload; the graph keeps Back. +test('#2176: a read-only graph draws no editing control and leaves the host sentence to the header', () => { const view = renderView({ phase: 'ready', pack, opened: { source: 'own', id: 'wf-mine' }, error: '', roster: { bots: [], readOnly: READ_ONLY } }) const editingControl = all(view).filter((entry) => entry.type === 'button' && (text(entry) === 'Edit' || text(entry) === 'Finish editing')) assert.deepEqual(editingControl.map(text), [], 'a read-only window offers no editing control') - const sentence = all(view).find((entry) => entry.props?.className === 'bt-wf-status' && text(entry) === READ_ONLY) - assert.ok(sentence, 'the host sentence is rendered on the page') - assert.equal(text(sentence), READ_ONLY, 'the page repeats the host wording, it does not invent one') + assert.equal(all(view).some((entry) => text(entry) === READ_ONLY), false, 'the edit bar adds no second copy of the host sentence') + assert.equal(all(view).some((entry) => entry.props?.role === 'status' && text(entry) === READ_ONLY), false, 'and no second live region carrying it') assert.equal(all(view).some((entry) => entry.props?.['data-port']), false, 'no port handles to drag') // The read side is untouched: the graph, its cards and the run chips still render. assert.ok(all(view).some((entry) => entry.props?.['data-node'] === 'coder'), 'the opened graph still renders') diff --git a/patches/ds-bot/files/test/workflows-readonly-once.test.js b/patches/ds-bot/files/test/workflows-readonly-once.test.js new file mode 100644 index 000000000..95e9c108b --- /dev/null +++ b/patches/ds-bot/files/test/workflows-readonly-once.test.js @@ -0,0 +1,159 @@ +// The standing read-only sentence belongs to the dialog header, once per page +// (settings-readonly-pages.test.js). That page list never opened a workflow, so it could not +// see the second copy: in a window that holds the team read-only, Settings → Workflows drew +// the host's sentence in the header and again in the graph's edit bar, a second live region +// read out twice. The edit bar now draws Back only — the header's Reload is the whole +// screen's way out — and a writable window keeps Edit where it was. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const { outputFiles } = await build({ + stdin: { + contents: [ + "export { SettingsDialog } from './src/client/settings.js'", + "export { WorkflowsView } from './src/client/workflows.js'", + ].join('\n'), + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) + +const h = (type, props, ...children) => ({ type, props: { ...props, children: children.flat(Infinity) } }) + +// One render pass: the dialog and the graph canvas both call hooks, and nothing here +// re-renders, so each component is drawn with a fresh set of slots. +let slots = [] +let index = 0 +const hook = () => { const at = index++; return at } +const react = { + createElement: h, Component: class {}, Fragment: Symbol('F'), + useMemo: (fn) => { const at = hook(); if (!(at in slots)) slots[at] = fn(); return slots[at] }, + useState: (initial) => { + const at = hook() + if (!(at in slots)) slots[at] = typeof initial === 'function' ? initial() : initial + return [slots[at], (value) => { slots[at] = typeof value === 'function' ? value(slots[at]) : value }] + }, + useCallback: (fn) => { const at = hook(); if (!(at in slots)) slots[at] = fn; return slots[at] }, + useRef: (value) => { const at = hook(); if (!(at in slots)) slots[at] = { current: value }; return slots[at] }, + useEffect: (fn) => { const at = hook(); if (!(at in slots)) slots[at] = fn }, + useLayoutEffect: () => { hook() }, + useId: () => { hook(); return 'id' }, + useSyncExternalStore: (_subscribe, get) => { hook(); return get() }, +} +const win = { location: { hash: '' }, addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) } +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, console, setTimeout, clearTimeout, + requestAnimationFrame: () => 0, cancelAnimationFrame() {}, + document: { hidden: false, addEventListener() {}, removeEventListener() {}, createElement: () => ({ getContext: () => ({}) }) }, + navigator: { language: 'en' }, + localStorage: { getItem: () => null, setItem() {} }, + window: win, + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return {} + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { SettingsDialog, WorkflowsView } = module.exports + +const SENTENCE = 'The Bot team in /home/xuke/.flybot is open in another dsh (Web or Desktop, pid 41 on host). Close the other one and reload.' + +const chief = { id: 'chief', name: 'Chief', role: 'Researcher', model: 'mock/m1', workDir: '/work/chief', instructions: 'Run the team.', pinned: true } +const baseRoster = { + bots: [chief], byId: { chief }, rooms: [], roomsById: {}, mainBotIds: ['chief'], mainBotId: 'chief', + prefs: { theme: 'deepseek', motion: 'quiet', notifications: true }, + models: [], connectors: [], capabilities: [], secrets: [], humans: [], remoteBots: [], memory: true, + questions: {}, exchangeTurns: {}, +} +const lockedRoster = { ...baseRoster, readOnly: SENTENCE } + +const lane = { + id: 'w1', + name: 'Hotfix lane', + description: 'A short lane.', + chain: 'Planner → Coder', + nodes: [ + { id: 'planner', name: 'Planner', role: 'Planner', x: 0, y: 0 }, + { id: 'coder', name: 'Coder', role: 'Coder', x: 0, y: 108 }, + ], + edges: [{ from: 'planner', to: 'coder' }], + layers: [['planner'], ['coder']], +} +const pack = { ok: true, templates: [], workflows: [lane] } + +// Every action a locked page could reach; a press that lands in one is a write. +const actions = () => new Proxy({ openSettings() {}, closeSettings() {}, openSession() {}, notify() {}, showDetails() {} }, { + get: (target, name) => (name in target ? target[name] : () => {}), +}) + +const text = (node) => { + if (Array.isArray(node)) return node.map(text).join('') + if (typeof node === 'string') return node + if (node && typeof node === 'object') return text(node.props?.children) + return '' +} + +// Draw the tree the way the screen would: a function component is called, a host element is +// kept. One pass is enough — the test asks what the first screen carries. +const draw = (node, out = [], depth = 0) => { + if (Array.isArray(node)) { for (const item of node) draw(item, out, depth); return out } + if (!node || typeof node !== 'object') return out + if (typeof node.type === 'function' && depth < 10) { + const saved = slots + const savedIndex = index + slots = [] + index = 0 + let rendered + try { rendered = node.type({ ...node.props }) } catch { out.push(node); return out } finally { slots = saved; index = savedIndex } + return draw(rendered, out, depth + 1) + } + out.push(node) + draw(node.props?.children, out, depth) + return out +} + +// The sentence as a reader meets it: one text block per drawn copy, whatever element and +// class carry it. A wrapper and the node inside it are one copy, not two. +const sentences = (tree) => draw(tree).filter(node => text(node) === SENTENCE + && !draw(node.props?.children).some(child => text(child) === SENTENCE)) + +const view = (over = {}) => h(WorkflowsView, { phase: 'ready', pack, opened: { source: 'own', id: 'w1' }, error: '', roster: lockedRoster, ...over }) +const dialog = (roster) => SettingsDialog({ page: 'workflows', id: null, roster, actions: actions(), useUpdate: () => ({}) }) +const buttons = (tree) => draw(tree).filter(node => node.type === 'button') + +test('a read-only window draws the host sentence once, not twice, with a workflow graph open', () => { + const header = sentences(dialog(lockedRoster)) + assert.equal(header.length, 1, 'the dialog header keeps the standing sentence') + assert.equal(header[0].props.role, 'status', 'the header sentence stays the live region') + assert.equal(text(header[0]), SENTENCE, "the header keeps the host's own wording") + + const graph = sentences(view()) + assert.equal(graph.length, 0, 'the open graph does not repeat the sentence the header already carries') + assert.equal(header.length + graph.length, 1, 'the screen carries the host sentence once') + assert.equal(draw(view()).some(node => node.props?.role === 'status' && text(node) === SENTENCE), false, + 'the edit bar adds no second live region') +}) + +test('the read-only graph keeps Back and adds no second Reload, Edit or Use this', () => { + const labels = buttons(view()).map(text) + assert.deepEqual(labels, ['Back'], 'Back is the only control the edit bar draws') + assert.equal(labels.includes('Reload'), false, "the header's Reload is the screen's one Reload") + assert.equal(labels.includes('Edit') || labels.includes('Finish editing'), false, 'the graph offers no editing control') + assert.equal(labels.includes('Use this'), false, 'the graph offers no copy of a preset') +}) + +test('a writable window keeps the graph editor and draws no read-only sentence', () => { + assert.equal(sentences(dialog(baseRoster)).length, 0, 'a writable header draws no sentence') + const tree = view({ roster: baseRoster }) + assert.equal(sentences(tree).length, 0, 'a writable graph draws no sentence') + const labels = buttons(tree).map(text) + assert.ok(labels.includes('Edit'), 'Edit stays in a writable window') + assert.equal(labels.includes('Reload'), false, 'a writable window draws no Reload') + assert.ok(draw(tree).some(node => node.props?.['data-node'] === 'coder'), 'the opened graph still renders') +})