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).