From 2927239487a27e2edeb6be752768d815c36e6efc Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:14:58 +0800 Subject: [PATCH] fix(workflow): fit the Settings graph to its canvas, without a nested scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Workflow graphs drew the same 960px design plane as the room drawer, inside its own overflow:auto box nested in the settings page's scroll, so the canvas carried a second vertical and horizontal scrollbar and its edges — which paint outside the zero-width plane — ran off and were cut at the border. GraphCanvas now measures its canvas, scales the design plane to that width through one .wf-canvas-fit layer and reports the scaled height, so the settings page keeps the only scroll axis and every node and edge stays inside the canvas. Pointer deltas are divided by the scale, so a drag still follows the pointer, and the canvas draws its wires with a stronger stroke than a plain divider so an edge can be followed at that size. The canvas stays the editing surface: same graph, same drag and wire handlers. --- changelog.d/1483.fixed.md | 2 +- docs/subsystems/workflow.md | 2 +- plugins/workflow/client.js | 36 +++++++++++--- plugins/workflow/test/client.test.js | 72 ++++++++++++++++++++++++++-- 4 files changed, 101 insertions(+), 11 deletions(-) diff --git a/changelog.d/1483.fixed.md b/changelog.d/1483.fixed.md index a0e10c1b5..5e522224f 100644 --- a/changelog.d/1483.fixed.md +++ b/changelog.d/1483.fixed.md @@ -1 +1 @@ -Settings → Workflow graphs improves edge visibility (darker, thicker strokes) and removes nested scrollbar container for cleaner single-axis scroll. +Settings → Workflow graphs no longer nests a scrollbar inside the settings page: the canvas scales the graph to the width it has, so the page keeps the only scroll axis and every node and edge stays inside the canvas. Its wires are drawn with a stronger stroke, and dragging a node still follows the pointer at that scale. diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 7a1c04729..7bca32b1b 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, ## Invariants 1. Friends' Bots are not nodes. A `remote` Bot is refused by create, define and the matcher. -2. The canvas and the run share one graph: nodes, edges and positions. A drag commits `workflow/graph`, but only while **Edit** is on — a factory preset is copied first (#1378). Status highlighting reads that same graph. Replacing edges does not recompute who is already running. +2. The canvas and the run share one graph: nodes, edges and positions. A drag commits `workflow/graph`, but only while **Edit** is on — a factory preset is copied first (#1378). Status highlighting reads that same graph. Replacing edges does not recompute who is already running. The Settings canvas paints that graph on the same 960px design plane, scaled to the width the settings page gives it, so the canvas is not a second scroll container inside the page's own scroll; its pointer deltas are divided by the scale, and its wires keep a stronger stroke than a plain divider so an edge can be followed at that size (#1483). 3. **A live run advances only when its ephemeral Bot replies, following the graph's edges in order.** That reply advances the run and may appear on the workflow stage log; it is not delivered into a persistent sidebar Bot conversation (for example Chief). The `workflow-step` send middleware denies those replies after `stepResult` runs. Nodes start only when their forward predecessors finish and edges reach them. **Early termination (#1537, #1538):** When a node completes with `needs-human` or `already-claimed` detected in its outputs or reply text (see `checkEarlyEnd` in Key files), the run ends immediately with status `done`, sets `run.earlyEnd` to the condition name, and does not advance to or spawn successor nodes. This allows Triage, Claim, or any step to terminate the pipeline cleanly when human intervention is required or the issue is already owned by another agent. There is no scripted or seeded run path: `workflow/state` exposes only `live === true` runs. A run does not create a schedule. `scheduleLabel` is display text. When the host can create Bots, starting a live run (`assign`, `run_workflow`, `ingest`) spawns one ephemeral Bot per node from that node's prompt, role, model and tools, sends it the step from the Main Bot, and removes every instance when the run finishes, fails for good, or is reset. A `failOnce` retry keeps them. They are hidden and left out of the sidebar. Loading the plugin does not spawn Bots for the default workflows. Plugin unload (`dispose`) and the load-time `reap` both end a live run still marked `running`: in-flight nodes are failed, the run is archived as `failed`, and any ephemeral Bot ids are released. A run already written as `running` with no ephemeral map (an older unload) is healed the same way on the next load (#1414). Loading is also the one moment a run the host left behind is noticed when its ephemeral map is still present: `reap` releases those instances and records the run as `failed`. Nothing resumes it. A run that already ended keeps its own status (#1388). `workflow/reset` stopping one live run ends it the same way before its Bots are released: nodes still `running` become `failed`, so the strip cannot keep painting a run that is over (#1424). A dispatch that does not land is not marked handed. The chat names the code, and the next hand-off tries that node again. It is not sent twice in the same hand-off. A reply that says `could not answer your message` or `model call failed` fails that node and does not start its successor. A 429 or rate-limit mention in any other reply does not. 4. Each workflow keeps a FIFO queue and up to `maxConcurrent` live runs (default 2, an integer from 1 to 8 on the record, via `workflow/concurrency`, or in Settings → Workflow graphs). A task that does not fit is enqueued. When a run finishes, every workflow with a free slot is drained, oldest enqueue first. A new assign on a workflow that already has a queue waits behind it. A task does not replace a running run or release that run's Bots. Migrating an old store keeps only runs that were still `running` with ephemeral Bot ids; other legacy scripted rows are dropped from active state (finished tasks may remain in lane history). History records a finished or failed live run. Lowering the cap does not stop a run that has already started. Assigning `{ botId }` claims the card for that local Bot and does not enqueue. A remote Bot is refused. `activeCount` is `runningOf(lane)` — live runs with `live === true` and `status === 'running'`. The stage aggregate chip and the Runs tab (运行记录) must agree: every such run appears as its own running row and progress card, and the chip count does not exceed that list. 5. A node declares up to eight inputs and eight outputs. A required input is an output of the same name on an ancestor, or `task`, `title`, or `brief` on a root. A start with no provider is refused, and Bots spawned for that attempt are released. On a Bot-backed run a successor stays queued until its required inputs are present. If every forward predecessor is done, failed or skipped and an input is still missing, that node fails and the run stops. The dispatch text carries `Input : `. The Bot reports `OUTPUT : `; a single declared output may be the reply body. Nodes start in edge order. diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index 1e0d5ddb0..2095e4dcc 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -1002,11 +1002,30 @@ window.__ModuleLoader__.load({ function GraphCanvas({ workflow, editing, selectedNode, onSelectNode, onCommit, roster }) { const [gesture, setGesture] = useState(null) + const stageRef = useRef(null) + const [panelWidth, setPanelWidth] = useState(0) const nodes = workflow?.nodes ?? [] const edges = workflow?.edges ?? [] const height = Math.max(420, ...nodes.map(node => (node.y ?? 0) + 150), 0) const run = workflow?.run?.live === true ? workflow.run : null const status = run?.nodeStatus ?? {} + // The graph is authored on the 960px design plane. Drawing it at that width + // inside the settings page grew a second scrollbar and cut edges at the + // border, so the plane is scaled down to the width the canvas has (#1483). + const scale = panelWidth > 0 ? Math.min(1, panelWidth / (WIRE_SPAN * 2)) : 1 + const drawnHeight = Math.round(height * scale) + useEffect(() => { + const el = stageRef.current + const measure = () => { + const measured = el?.getBoundingClientRect?.().width + if (Number.isFinite(measured) && measured > 0 && Math.abs(measured - panelWidth) > 0.5) setPanelWidth(measured) + } + measure() + if (typeof ResizeObserver !== 'function' || !el) return undefined + const observer = new ResizeObserver(measure) + observer.observe(el) + return () => observer.disconnect() + }, [panelWidth]) const commit = (nextNodes, nextEdges) => onCommit?.({ nodes: nextNodes, edges: nextEdges }) @@ -1014,10 +1033,12 @@ window.__ModuleLoader__.load({ if (!gesture || gesture.kind !== 'move') return const node = nodes.find(n => n.id === gesture.id) if (!node) return + // The plane is scaled to the canvas, so a pointer delta in screen pixels + // is a larger delta in the graph's own coordinates. commit(nodes.map(n => n.id === gesture.id ? { ...n, - x: Math.round((node.x ?? 0) + event.clientX - gesture.ox), - y: Math.round((node.y ?? 0) + event.clientY - gesture.oy) + x: Math.round((node.x ?? 0) + (event.clientX - gesture.ox) / scale), + y: Math.round((node.y ?? 0) + (event.clientY - gesture.oy) / scale) } : n), edges) setGesture({ ...gesture, ox: event.clientX, oy: event.clientY }) } @@ -1031,8 +1052,9 @@ window.__ModuleLoader__.load({ setGesture(null) } - return h('div', { className: 'wf-canvas-view', onPointerMove: movePointer, onPointerUp: endPointer }, - h('div', { className: 'wf-plane', 'data-plane': '1', style: { height } }, + return h('div', { className: 'wf-canvas-view', ref: stageRef, onPointerMove: movePointer, onPointerUp: endPointer }, + h('div', { className: 'wf-plane', 'data-plane': '1', style: { width: panelWidth, height: drawnHeight } }, + h('div', { className: 'wf-canvas-fit', style: { height, transform: `scale(${scale})` } }, h('svg', { className: 'wf-wires', 'aria-hidden': true, ...wireSvgProps(height) }, edges.map(edge => { const from = nodes.find(node => node.id === edge.from) @@ -1063,7 +1085,7 @@ window.__ModuleLoader__.load({ setGesture({ kind: 'wire', from: node.id }) }, }) : null, - h(FlowNode, { node, status: status[node.id] ?? 'idle', roster: roster ?? {} }))))) + h(FlowNode, { node, status: status[node.id] ?? 'idle', roster: roster ?? {} })))))) } function conversationLeft() { @@ -1515,7 +1537,9 @@ window.__ModuleLoader__.load({ .wf-settings-header{display:flex;gap:12px;align-items:center;flex-wrap:wrap} .wf-autosave-hint{font-size:11px;color:var(--bt-ink-3,#9a9a9a)} .wf-canvas-main{flex:1;min-height:500px;display:flex;flex-direction:column;border:1px solid var(--bt-line,rgba(20,20,20,.08));border-radius:12px;padding:16px;background:var(--bt-card,#fff)} -.wf-canvas-view{width:100%;height:100%;min-height:0;position:relative;overflow:auto} +.wf-canvas-view{width:100%;height:100%;min-height:0;position:relative;overflow:visible} +.wf-canvas-fit{position:relative;width:0;margin:0 auto;transform-origin:50% 0} +.wf-canvas-view .wf-wires path{stroke:var(--bt-ink-2,rgba(20,20,20,.55));stroke-width:2.5} .wf-cards-empty{padding:8px 4px 12px} .wf-context-menu{position:fixed;z-index:95;min-width:140px;background:var(--bt-card,#fff);border:1px solid var(--bt-line,rgba(20,20,20,.08));border-radius:10px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,.12);display:flex;flex-direction:column;gap:2px} .wf-context-menu button{border:0;background:transparent;color:inherit;font:inherit;font-size:12.5px;text-align:left;padding:8px 10px;border-radius:8px;cursor:pointer} diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index 2e6bd08d5..74b979cec 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -4,7 +4,9 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { test } from 'node:test' -const live = { inst: null, idx: 0 } +// `rectWidth` is what a host element reports from getBoundingClientRect, so a +// component that measures its own container can be driven from a plain object. +const live = { inst: null, idx: 0, rectWidth: 0 } const react = { createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), useState(init) { @@ -47,6 +49,14 @@ function mount(Component, props = {}) { live.idx = 0 return expand(node.type({ ...node.props, children: node.children }), `${path}/c`) } + // The renderer has no DOM: a host element's ref gets a stand-in that answers + // getBoundingClientRect from `live.rectWidth`. + const ref = node.props?.ref + if (ref) { + const el = { getBoundingClientRect: () => ({ width: live.rectWidth, height: 0 }), dataset: {} } + if (typeof ref === 'function') ref(el) + else if (typeof ref === 'object') ref.current = el + } return { type: node.type, props: node.props, children: node.children.map((child, i) => expand(child, `${path}.${i}`)).flat(Infinity).filter(child => child !== undefined) } } const render = () => { @@ -425,6 +435,60 @@ test('settings can add a loop edge and a condition edge', async () => { settings.unmount() }) +test('the settings canvas fits the graph to its own width, and a drag follows that scale', async () => { + // Settings → Workflow graphs: the canvas reports 640px, so the 960px design + // plane is scaled down instead of growing a second scrollbar (#1483). + live.rectWidth = 640 + try { + const calls = [] + const graph = delivery() + const call = async (name, body) => { + calls.push({ name, body }) + if (name === 'workflow/state') return { workflows: [graph] } + return { ok: true, workflows: [graph] } + } + const settings = mount(client.WorkflowSettings, { call, roster: { bots: [] } }) + await settle() + + const designW = client.WIRE_SPAN * 2 + const plane = findAll(settings.tree, node => node.props?.['data-plane'] === '1')[0] + assert.ok(plane, 'the settings page draws a graph plane') + const width = Number(plane.props.style.width) + assert.equal(width, 640, 'the canvas draws the graph at the width it has, not on a 960px plane') + const scale = width / designW + const fit = findAll(settings.tree, node => node.props?.className === 'wf-canvas-fit')[0] + assert.ok(fit, 'the graph is drawn through a scaling layer') + assert.equal(fit.props.style.transform, `scale(${scale})`) + assert.equal(Number(plane.props.style.height), Math.round(Math.max(420, ...graph.nodes.map(node => (node.y ?? 0) + 150)) * scale)) + + for (const node of graph.nodes) { + const drawn = findAll(settings.tree, item => item.props?.['data-node'] === node.id)[0] + const left = Number(drawn.props.style.left) + assert.ok(left * scale >= -width / 2 - 0.5, `${node.id} starts inside the canvas`) + assert.ok((left + client.NODE_W) * scale <= width / 2 + 0.5, `${node.id} ends inside the canvas`) + } + const wires = findAll(settings.tree, node => node.props?.className === 'wf-wires')[0] + assert.equal(Number(wires.props.width) * scale, width, 'the wire layer spans exactly the canvas width once scaled') + for (const path of findAll(settings.tree, node => node.props?.className !== undefined && typeof node.props.d === 'string' && node.props.d.startsWith('M '))) { + for (const x of [...path.props.d.matchAll(/(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/g)].map(match => Number(match[1]))) { + assert.ok(x >= 0 && x <= designW, 'a wire stays inside the wire layer, not off the canvas border') + } + } + + button(settings.tree, 'Edit').props.onClick() + const coder = findAll(settings.tree, node => node.props?.['data-node'] === 'coder')[0] + coder.props.onPointerDown({ clientX: 10, clientY: 20, preventDefault() {}, target: { dataset: {} } }) + const canvas = findAll(settings.tree, node => node.props?.className === 'wf-canvas-view')[0] + canvas.props.onPointerMove({ clientX: 58, clientY: 44 }) + const saved = calls.filter(item => item.name === 'workflow/graph').at(-1).body.nodes.find(node => node.id === 'coder') + assert.equal(saved.x, Math.round(200 + 48 / scale), 'the pointer delta is divided by the scale') + assert.equal(saved.y, Math.round(260 + 24 / scale)) + settings.unmount() + } finally { + live.rectWidth = 0 + } +}) + test('sidebar preview ignores a stale busy preview when nothing is live', async () => { const graph = delivery() graph.preview = 'Coder 正在处理 #1280…' @@ -651,10 +715,12 @@ test('the hand-a-task modal lists live Tasks board rows only', async () => { view.unmount() }) -test('the flowchart canvas CSS clips overflow inside its panel', () => { +test('the room flowchart canvas scrolls in its panel, and the settings canvas leaves one scroll to the page', () => { const source = readFileSync(new URL('../client.js', import.meta.url), 'utf8') assert.match(source, /\.wf-canvas\{[^}]*overflow:auto/) - assert.match(source, /\.wf-canvas-view\{[^}]*overflow:auto/) + // Settings → Workflow graphs fits its graph to the canvas width, so the + // canvas is not a scroll container inside the page's own scroll (#1483). + assert.match(source, /\.wf-canvas-view\{[^}]*overflow:visible/) assert.match(source, /\.wf-stage\{[^}]*overflow:hidden/) })