From b4aa03e7f8ed0200a2cb880309a435b4da8ff262 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:35:59 +0800 Subject: [PATCH] fix(workflow): fit the room Flow tab to its panel The drawer's Flow tab drew the graph on a fixed 960px canvas inside a 400px panel, so most nodes sat behind an inner horizontal scrollbar and the wire layer, which paints outside its box, was clipped into orphan and half edges. Flow now scales its 960px design space down to the canvas width it measures (falling back to the drawer's content width), draws the wires and nodes through one .wf-fit layer, and reports the scaled height to the plane, so the panel has a single vertical scroll and every node and edge stays inside it. Pointer deltas are divided by the scale, and a dropped palette Bot is mapped back through the panel centre and the scale, so dragging still lands where the pointer is. The drawer stays the editing surface: same graph, same drag, wire and inspector handlers, only fitted to the panel. Docs and the changelog fragment updated. --- changelog.d/1513.fixed.md | 1 + docs/subsystems/workflow.md | 4 +- plugins/workflow/client.js | 57 ++++++++++++++++++++++----- plugins/workflow/test/client.test.js | 59 ++++++++++++++++++++++++++-- 4 files changed, 106 insertions(+), 15 deletions(-) create mode 100644 changelog.d/1513.fixed.md diff --git a/changelog.d/1513.fixed.md b/changelog.d/1513.fixed.md new file mode 100644 index 000000000..d83758fc7 --- /dev/null +++ b/changelog.d/1513.fixed.md @@ -0,0 +1 @@ +The Workflow room's right-hand Flow tab no longer draws on a fixed 960px canvas inside its ~400px panel. The graph is scaled to the panel width, so every node and edge is visible without an inner horizontal scrollbar, and dragging a node or dropping a palette Bot still lands where the pointer is. diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 2cda57cf5..63c8aa029 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -33,7 +33,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` | 发布交付流水线 (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), flowchart drawer (collapsed by default, per-node inspector), 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. The stage and dialogs portal onto `document.body`; a missing container stays in the section tree. | +| `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), flowchart drawer (collapsed by default, per-node inspector), 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. The room drawer draws its 960px graph on a canvas exactly as wide as the panel (`width: FIT_WIDTH`, `transform: scale(width/960)` on the inner `.wf-fit` layer), so the Flow tab has one vertical scroll and no inner horizontal scroll, and every node is inside the panel. Pointer deltas are divided by that scale, so a drag or a dropped palette Bot lands where the pointer is. The stage and dialogs portal onto `document.body`; a missing container stays in the section tree. | ## Entry points @@ -55,7 +55,7 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, 9. The browser draws text only. Names and messages are React text children. The unit renderer asserts a hostile name is not markup. 10. `enabled: false` registers no endpoint, tool or listener and writes nothing. 11. A sidebar click opens the stage or a dialog and leaves the Workflows section in place. The stage and the dialogs portal onto `document.body`. When that container is missing they stay in the section tree; the click does not throw. -12. A node's prompt and the graph are one record. Settings → Workflow graphs (canvas-first with per-node inspector), the flowchart drawer (collapsed by default, inspector on select), and `define_workflow` all write `workflow/graph`. A run reads that record; it does not define it. After a live run finishes, the stage shows the last finished live run (`lane.last` with `live === true`) until a new live run starts. +12. A node's prompt and the graph are one record. Settings → Workflow graphs (canvas-first with per-node inspector), the flowchart drawer (collapsed by default, inspector on select), and `define_workflow` all write `workflow/graph`. A run reads that record; it does not define it. After a live run finishes, the stage shows the last finished live run (`lane.last` with `live === true`) until a new live run starts. The drawer's Flow tab stays the editing surface: it is the same graph and the same drag, wire and inspector handlers, only fitted to the panel width, and its pointer deltas are divided by that fit scale. ## Tests diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index 1e0d5ddb0..9b266de21 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -138,6 +138,12 @@ window.__ModuleLoader__.load({ const NODE_WIRE_OUT = 56 const NODE_W = 168 const NODE_H = 72 + // The room drawer is 400px wide with an 18px body padding on each side. The + // flow is drawn on a 960px design canvas and scaled down to that width, so + // the panel never grows an inner horizontal scrollbar (#1513). + const DRAWER_W = 400 + const DRAWER_PAD = 18 + const FIT_WIDTH = DRAWER_W - DRAWER_PAD * 2 function workflowNodeIds(workflow) { return new Set((workflow?.nodes ?? []).map(node => node.id)) @@ -441,7 +447,7 @@ window.__ModuleLoader__.load({ ['flow', 'details', 'runs'].map(key => h('button', { key, type: 'button', className: tab === key ? 'on' : '', onClick: () => onTab(key), }, tr(key === 'flow' ? 'Flow' : key === 'details' ? 'Details' : 'Runs')))), - tab === 'flow' ? h(Flow, { workflow, run, roster, onTip, editing, onCommit }) : null, + tab === 'flow' ? h(Flow, { workflow, run, roster, onTip, editing, onCommit, width: FIT_WIDTH }) : null, tab === 'details' ? h(Details, { workflow, roster }) : null, tab === 'runs' ? h(Runs, { workflow }) : null, h('div', { className: 'wf-ctl' }, @@ -522,6 +528,12 @@ window.__ModuleLoader__.load({ return rows } + function fitScale(width) { + const measured = Number(width) + if (!Number.isFinite(measured) || measured <= 0) return 1 + return Math.min(1, measured / (WIRE_SPAN * 2)) + } + function wireSvgProps(height) { return { width: WIRE_SPAN * 2, @@ -530,15 +542,33 @@ window.__ModuleLoader__.load({ } } - function Flow({ workflow, run, roster, onTip, editing, onCommit }) { + function Flow({ workflow, run, roster, onTip, editing, onCommit, width }) { const [gesture, setGesture] = useState(null) const [ghost, setGhost] = useState(null) const [dragPreview, setDragPreview] = useState(null) const [selectedNode, setSelectedNode] = useState(null) + const stageRef = useRef(null) + const [panelWidth, setPanelWidth] = useState(() => (Number.isFinite(Number(width)) && Number(width) > 0 ? Number(width) : 0)) const status = run?.nodeStatus ?? {} const nodes = workflow.nodes.map(node => ghost?.[node.id] ? { ...node, ...ghost[node.id] } : node) const edges = workflow.edges ?? [] const height = Math.max(420, ...nodes.map(node => (node.y ?? 0) + 150), 0) + const scale = fitScale(panelWidth) + const drawnHeight = Math.round(height * scale) + useEffect(() => { + const el = stageRef.current + const measure = () => { + // The panel may be narrower than the drawer (body padding, a scrollbar), + // and the graph must fit whatever width the canvas actually has. + const measured = el?.getBoundingClientRect?.().width + if (Number.isFinite(measured) && measured > 0 && measured !== panelWidth) setPanelWidth(Math.min(measured, WIRE_SPAN * 2)) + } + measure() + if (typeof ResizeObserver !== 'function' || !el) return undefined + const observer = new ResizeObserver(measure) + observer.observe(el) + return () => observer.disconnect() + }, [width, panelWidth]) const locals = (roster?.bots ?? []).filter(bot => bot && bot.remote !== true && !nodes.some(node => node.botId === bot.id)) const selected = nodes.find(node => node.id === selectedNode) const commit = (nextNodes, nextEdges) => onCommit?.({ nodes: nextNodes, edges: nextEdges }) @@ -549,7 +579,9 @@ window.__ModuleLoader__.load({ return } if (gesture.kind !== 'move') return - setGhost({ [gesture.id]: { x: Math.round(gesture.x + event.clientX - gesture.ox), y: Math.round(gesture.y + event.clientY - gesture.oy) } }) + // The plane is scaled down to the panel width, so a pointer delta in + // screen pixels is a larger delta in the graph's own coordinates. + setGhost({ [gesture.id]: { x: Math.round(gesture.x + (event.clientX - gesture.ox) / scale), y: Math.round(gesture.y + (event.clientY - gesture.oy) / scale) } }) } const endPointer = (event) => { if (!gesture) return @@ -565,8 +597,12 @@ window.__ModuleLoader__.load({ else if (!hit) commit(workflow.nodes, rest) } else if (gesture.kind === 'add' && event.target?.dataset?.plane) { const bot = gesture.bot - const x = Number.isFinite(event.offsetX) ? Math.round(event.offsetX) : 24 - const y = Number.isFinite(event.offsetY) ? Math.round(event.offsetY) : 24 + // offsetX/Y are panel pixels; the graph keeps the 960px design space, + // so shift by the panel centre and undo the fit scale. + const left = Number.isFinite(event.offsetX) ? event.offsetX : panelWidth / 2 + const top = Number.isFinite(event.offsetY) ? event.offsetY : 0 + const x = Math.round((left - panelWidth / 2) / scale) + const y = Math.round(top / scale) commit(workflow.nodes.concat([{ id: nextNodeId(workflow.nodes), name: bot.name, botId: bot.id, role: bot.role ?? '', x, y }]), edges) } setGesture(null) @@ -575,7 +611,7 @@ window.__ModuleLoader__.load({ const updateNode = (nodeId, updates) => { commit(nodes.map(item => item.id === nodeId ? { ...item, ...updates } : item), edges) } - return h('div', { className: 'wf-canvas', onPointerMove: movePointer, onPointerUp: endPointer, onPointerCancel: () => { setGesture(null); setGhost(null); setDragPreview(null) } }, + return h('div', { className: 'wf-canvas', ref: stageRef, onPointerMove: movePointer, onPointerUp: endPointer, onPointerCancel: () => { setGesture(null); setGhost(null); setDragPreview(null) } }, editing ? h('div', { className: 'wf-palette' }, h('p', { className: 'wf-palette-hint' }, tr('Drag a Bot here')), h('div', { className: 'wf-palette-row' }, @@ -596,7 +632,8 @@ window.__ModuleLoader__.load({ 'aria-hidden': true, }, h(PaletteBotFace, { bot: gesture.bot, size: 24 }), h('span', { className: 'wf-drag-ghost-name' }, gesture.bot?.name ?? '')) : null, run ? h('div', { className: 'wf-term', title: run.task?.title ?? '' }, taskSummary(run.task, run)) : null, - h('div', { className: 'wf-plane', 'data-plane': '1', style: { height } }, + h('div', { className: 'wf-plane wf-plane-fit', 'data-plane': '1', style: { width: panelWidth, height: drawnHeight } }, + h('div', { className: 'wf-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) @@ -643,7 +680,7 @@ window.__ModuleLoader__.load({ setSelectedNode(null) }, }, '×') : null, - h(FlowNode, { node, status: status[node.id] ?? 'queued', roster })))), + h(FlowNode, { node, status: status[node.id] ?? 'queued', roster }))))), run?.status === 'done' ? h('div', { className: 'wf-term on' }, tr('Done')) : null, editing && selected ? h('div', { className: 'wf-node-inspector' }, h('div', { className: 'wf-inspector-header' }, @@ -1426,7 +1463,7 @@ window.__ModuleLoader__.load({ .wf-composer input{flex:1;border:0;outline:0;background:transparent;font:inherit;color:inherit} .wf-mic{width:28px;height:28px;border-radius:9px;border:0;background:var(--bt-accent,#4d6bfe);color:#fff;cursor:pointer} .wf-under{text-align:center;margin-top:6px;font-size:11px;color:var(--bt-ink-2,rgba(20,20,20,.6))} -.wf-drawer{width:400px;flex:none;min-width:0;overflow:hidden;border-left:1px solid var(--bt-line,rgba(20,20,20,.08));display:flex;flex-direction:column;background:var(--bt-main,#fcfcfc)} +.wf-drawer{width:${DRAWER_W}px;flex:none;min-width:0;overflow:hidden;border-left:1px solid var(--bt-line,rgba(20,20,20,.08));display:flex;flex-direction:column;background:var(--bt-main,#fcfcfc)} .wf-dr-head{display:flex;justify-content:flex-end;padding:12px} .wf-icon{width:28px;height:28px;border-radius:8px;border:1px solid var(--bt-line,rgba(20,20,20,.08));background:var(--bt-card,#fff);cursor:pointer;color:inherit} .wf-dr-body{flex:1;overflow:auto;padding:4px 18px 20px} @@ -1455,6 +1492,8 @@ window.__ModuleLoader__.load({ .wf-drag-ghost{position:fixed;z-index:100;pointer-events:none;display:inline-flex;align-items:center;gap:6px;padding:5px 10px 5px 5px;border-radius:10px;border:1px solid var(--bt-accent,#4d6bfe);background:var(--bt-card,#fff);box-shadow:0 8px 20px rgba(20,20,20,.12);opacity:.92;transform:translate(-4px,-4px)} .wf-drag-ghost-name{font-size:12px;font-weight:600;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .wf-plane{position:relative;width:0;margin:8px auto 0;--wf-wire-span:${WIRE_SPAN}px} +.wf-plane-fit{margin:8px auto 0} +.wf-fit{position:relative;width:0;height:100%;margin:0 auto;transform-origin:50% 0} .wf-node-inspector{position:absolute;bottom:8px;right:8px;width:320px;background:var(--bt-card,#fff);border:1px solid var(--bt-line,rgba(20,20,20,.08));border-radius:12px;padding:12px;box-shadow:0 4px 12px rgba(0,0,0,.1);z-index:10} .wf-inspector-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;font-size:12px;font-weight:600} .wf-close-inspector{background:none;border:0;cursor:pointer;color:var(--bt-ink-3,#9a9a9a);font-size:18px;line-height:1;padding:0;width:20px;height:20px} diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index 2e6bd08d5..ff97f784b 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -197,6 +197,12 @@ test('the sidebar opens the flowchart, and a drag, a wire and a dropped Bot are assert.ok(textOf(view.tree).includes(SCRIPT)) assert.ok(textOf(view.tree).includes(HOSTILE)) + // The drawer scales its 960px graph down to the panel, so a pointer delta in + // panel pixels is a larger delta in graph coordinates (#1513). + const plane = findAll(view.tree, node => node.props?.['data-plane'] === '1')[0] + const panelWidth = Number(plane.props.style.width) + const scale = panelWidth / (client.WIRE_SPAN * 2) + button(view.tree, 'Edit').props.onClick() const coder = findAll(view.tree, node => node.props?.['data-node'] === 'coder')[0] coder.props.onPointerDown({ clientX: 10, clientY: 20, target: { dataset: {} } }) @@ -205,8 +211,8 @@ test('the sidebar opens the flowchart, and a drag, a wire and a dropped Bot are canvas = findAll(view.tree, node => node.props?.className === 'wf-canvas')[0] canvas.props.onPointerUp({ clientX: 58, clientY: 44, target: { dataset: {} } }) const moved = calls.find(item => item.name === 'workflow/graph') - assert.equal(moved.body.nodes.find(node => node.id === 'coder').x, 248) - assert.equal(moved.body.nodes.find(node => node.id === 'coder').y, 284) + assert.equal(moved.body.nodes.find(node => node.id === 'coder').x, Math.round(200 + 48 / scale)) + assert.equal(moved.body.nodes.find(node => node.id === 'coder').y, Math.round(260 + 24 / scale)) const port = findAll(view.tree, node => node.props?.['data-port'] === 'out' && node.props['aria-label'] === 'Writer')[0] port.props.onPointerDown({ preventDefault() {}, stopPropagation() {}, target: { dataset: { port: 'out' } } }) @@ -224,8 +230,8 @@ test('the sidebar opens the flowchart, and a drag, a wire and a dropped Bot are const added = calls.filter(item => item.name === 'workflow/graph').at(-1) const seat = added.body.nodes.find(node => node.botId === 'bot-docs') assert.equal(seat.name, 'Docs') - assert.equal(seat.x, 30) - assert.equal(seat.y, 40) + assert.equal(seat.x, Math.round((30 - panelWidth / 2) / scale)) + assert.equal(seat.y, Math.round(40 / scale)) button(view.tree, 'New').props.onClick() const template = findAll(view.tree, node => node.type === 'button' && node.props.className === 'wf-btn' && textOf(node) === '发布交付流水线')[0] @@ -235,6 +241,51 @@ test('the sidebar opens the flowchart, and a drag, a wire and a dropped Bot are view.unmount() }) +test('the room Flow tab fits the graph to the panel, and a drag follows that scale', async () => { + const calls = [] + const graph = delivery() + const call = async (name, body) => { + calls.push({ name, body }) + if (name === 'workflow/state') return { workflows: [graph], templates: [{ id: 'delivery', name: '发布交付流水线' }] } + return { ok: true, workflows: [graph] } + } + const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + await settle() + findAll(view.tree, node => node.type === 'button' && textOf(node).includes('发布交付流水线'))[0].props.onClick() + findAll(view.tree, node => node.type === 'button' && textOf(node) === 'Flow')[0].props.onClick() + + const designW = client.WIRE_SPAN * 2 + const plane = findAll(view.tree, node => node.props?.['data-plane'] === '1')[0] + assert.ok(plane, 'the drawer draws a flow plane') + const width = Number(plane.props.style.width) + assert.ok(width > 0 && width < designW, 'the panel draws the graph at its own width, not on a 960px canvas') + const scale = width / designW + const fit = findAll(view.tree, node => node.props?.className === 'wf-fit')[0] + assert.ok(fit, 'the graph is drawn through a scaling layer') + assert.equal(fit.props.style.transform, `scale(${scale})`) + assert.equal(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(view.tree, item => item.props?.['data-node'] === node.id)[0] + const left = Number(drawn.props.style.left) + const centre = (left + client.NODE_W / 2) * scale + assert.ok(centre - (client.NODE_W / 2) * scale >= -width / 2 - 0.5, `${node.id} starts inside the panel`) + assert.ok(centre + (client.NODE_W / 2) * scale <= width / 2 + 0.5, `${node.id} ends inside the panel`) + } + + button(view.tree, 'Edit').props.onClick() + const coder = findAll(view.tree, node => node.props?.['data-node'] === 'coder')[0] + coder.props.onPointerDown({ clientX: 10, clientY: 20, target: { dataset: {} } }) + let canvas = findAll(view.tree, node => node.props?.className === 'wf-canvas')[0] + canvas.props.onPointerMove({ clientX: 58, clientY: 44 }) + canvas = findAll(view.tree, node => node.props?.className === 'wf-canvas')[0] + canvas.props.onPointerUp({ clientX: 58, clientY: 44, target: { dataset: {} } }) + 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)) + view.unmount() +}) + test('clicking a sidebar workflow, New, or Hand-a-task keeps the section and opens the stage', async () => { // The shell's createPortal throws when the container is missing — the same // throw the sidebar slot boundary turns into an empty Workflows section (#1375).