From 74de9c6c77e140bc34c4ef2f41f2125ebdc24b9a Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Wed, 28 Oct 2026 14:58:47 +0800 Subject: [PATCH] fix(workflow): fit the room Flow tab to its panel The room's right-hand Flow tab painted the Settings world canvas in a 400px panel, so nodes past the first column, their wires and the parallel-branch label were cut at the panel edge behind an inner horizontal scrollbar. fitPlane() scales node centres (never the node boxes) into the drawer's 364px content box; the drawer paints the fitted nodes, wires and frames, and a drag or drop divides its pointer delta by the fit scale so the stored graph keeps world coordinates. Labels wrap, the inner horizontal scroll is gone, and the Settings canvas keeps its own behaviour (#1536). Replayed onto main 176d33d034e741b68041c0676da1707001cb6e88 (#1535, #1497, #1536, #1522 landed after the original base). The fitted drawer keeps the plain wf-canvas class and is selected by data-fit="drawer", so the stage-control tests and main's canvas lookup both hold; the docs row keeps both sentences. Fixes #1513 Written-by: Rework --- changelog.d/1513-workflow-panel-fit.fixed.md | 1 + docs/subsystems/workflow.md | 4 +- plugins/workflow/client.js | 143 +++++++++++++------ plugins/workflow/test/client.test.js | 99 +++++++++++-- 4 files changed, 192 insertions(+), 55 deletions(-) create mode 100644 changelog.d/1513-workflow-panel-fit.fixed.md diff --git a/changelog.d/1513-workflow-panel-fit.fixed.md b/changelog.d/1513-workflow-panel-fit.fixed.md new file mode 100644 index 000000000..e1e6ccc74 --- /dev/null +++ b/changelog.d/1513-workflow-panel-fit.fixed.md @@ -0,0 +1 @@ +The Workflow room's right-hand Flow tab fits the whole graph to its 400px panel: every node, wire endpoint and parallel-branch frame stays inside the panel, labels wrap instead of being cut mid-word, and the inner horizontal scrollbar is gone. The drawer stays editable — drag and drop still store the same world coordinates, now divided by the fit scale — while Settings keeps its scrolling canvas. ([#1513](https://github.com/ThinkFlowLab/FlyBot/issues/1513)) diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 9d32ad9d7..67fc31d81 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. A stage control that fails — a graph save, Reset, Pause, Resume or Stop that throws, rejects, answers `{ok:false}` or does not answer within 10 s — ends on the section's `.wf-err` alert instead of being swallowed, and a success clears the alert. | +| `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, fitted to the 400px panel so the graph needs no inner horizontal scroll), and Settings → Workflow graphs (canvas-first with inspector panel). The product UI starts only live runs (assign, ingest). The stage shows the queue and refreshes state while runs are active; a running node uses a breathing border animation. Visual flowcharts replace text chains across all surfaces. Sidebar rows (including factory ids such as `delivery`) support Delete with confirm. The stage and dialogs portal onto `document.body`; a missing container stays in the section tree. A stage control that fails — a graph save, Reset, Pause, Resume or Stop that throws, rejects, answers `{ok:false}` or does not answer within 10 s — ends on the section's `.wf-err` alert instead of being swallowed, and a success clears the alert. | ## 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`. The drawer paints that record fitted to its fixed 400px panel: `fitPlane` scales node centres (never the node boxes) into the panel width, wires and parallel frames use the fitted positions, and a pointer delta is divided by the fit scale before it is stored, so an edit still commits the same world coordinates. The Settings canvas paints the same record scaled to its own width (invariant 2), not as a second scroll container. 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. ## Tests diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index 4c04f4a76..6725787a4 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -176,6 +176,11 @@ window.__ModuleLoader__.load({ const NODE_WIRE_OUT = 56 const NODE_W = 168 const NODE_H = 72 + // The flowchart drawer is a fixed 400px panel with 18px side padding, so its + // Flow tab fits the graph into 364px. Settings keeps its own scrolling world + // plane; only the drawer fits every node and edge to the panel width (#1513). + const DRAWER_FLOW_W = 364 + const FIT_PAD = 12 function workflowNodeIds(workflow) { return new Set((workflow?.nodes ?? []).map(node => node.id)) @@ -500,10 +505,13 @@ window.__ModuleLoader__.load({ return (nested ?? direct)?.dataset?.node || '' } - function wirePath(from, to) { - const x1 = (from.x ?? 0) + WIRE_SPAN + // The Settings plane is zero-width, so world x sits on a ±WIRE_SPAN centre + // line; the fitted drawer plane starts at 0, so it passes span 0 (#1513). + function wirePath(from, to, span = WIRE_SPAN) { + const shift = typeof span === 'number' ? span : WIRE_SPAN + const x1 = (from.x ?? 0) + shift const y1 = (from.y ?? 0) + NODE_WIRE_OUT - const x2 = (to.x ?? 0) + WIRE_SPAN + const x2 = (to.x ?? 0) + shift const y2 = to.y ?? 0 if (Math.abs(x1 - x2) < 0.5) { const mid = (y1 + y2) / 2 @@ -527,6 +535,35 @@ window.__ModuleLoader__.load({ } } + // Fit a graph to a panel width: only the node centres scale, never the node + // boxes, so labels stay readable. Positions come back in panel coordinates + // (x from the left edge, y from the top), the graph is centred horizontally, + // and `scale` is never above 1 so a wide Settings canvas never enlarges it. + // It maps world → fitted; `offsetX` / `offsetY` invert a pointer position. + function fitPlane(nodes, panelWidth = DRAWER_FLOW_W) { + const width = Math.max(Number(panelWidth) || DRAWER_FLOW_W, NODE_W + FIT_PAD * 2) + const list = (nodes ?? []).filter(node => node && typeof node === 'object') + const xs = list.map(node => Number(node.x) || 0) + const ys = list.map(node => Number(node.y) || 0) + const minX = xs.length ? Math.min(...xs) : 0 + const maxX = xs.length ? Math.max(...xs) : 0 + const minY = ys.length ? Math.min(...ys) : 0 + const maxY = ys.length ? Math.max(...ys) : 0 + const span = maxX - minX + const room = Math.max(0, width - FIT_PAD * 2 - NODE_W) + const scale = span > 0 ? Math.min(1, room / span) : 1 + const offsetX = width / 2 - ((minX + maxX) / 2) * scale + const offsetY = FIT_PAD - minY + return { + width, + scale, + offsetX, + offsetY, + height: Math.max(420, maxY + NODE_H + FIT_PAD + offsetY), + nodes: list.map(node => ({ ...node, x: (node.x ?? 0) * scale + offsetX, y: (node.y ?? 0) + offsetY })), + } + } + function liveStageRuns(workflow) { let liveRuns = (workflow.runs ?? []).filter(run => run?.live === true && (run.status === 'running' || run.status === 'failed')) if (liveRuns.length === 0 && workflow.run?.live === true && (workflow.run.status === 'running' || workflow.run.status === 'failed')) { @@ -560,11 +597,11 @@ window.__ModuleLoader__.load({ return rows } - function wireSvgProps(height) { + function wireSvgProps(height, width = WIRE_SPAN * 2) { return { - width: WIRE_SPAN * 2, + width, height, - viewBox: `0 0 ${WIRE_SPAN * 2} ${height}`, + viewBox: `0 0 ${width} ${height}`, } } @@ -576,7 +613,12 @@ window.__ModuleLoader__.load({ 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) + // The drawer paints a fitted copy; `nodes` stays the world graph so a drag + // commits the same coordinates the Settings canvas would (#1513). + const plane = fitPlane(nodes, DRAWER_FLOW_W) + const fitted = new Map(plane.nodes.map(node => [node.id, node])) + const scale = plane.scale || 1 + const height = plane.height 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 }) @@ -587,7 +629,10 @@ 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 panel is fitted, so a pointer delta is worth delta / scale in the + // stored world graph; the scale is the one the drag started with. + const k = gesture.scale || 1 + setGhost({ [gesture.id]: { x: Math.round(gesture.x + (event.clientX - gesture.ox) / k), y: Math.round(gesture.y + event.clientY - gesture.oy) } }) } const endPointer = (event) => { if (!gesture) return @@ -603,8 +648,8 @@ 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 + const x = Number.isFinite(event.offsetX) ? Math.round((event.offsetX - plane.offsetX) / scale) : 24 + const y = Number.isFinite(event.offsetY) ? Math.round(event.offsetY - plane.offsetY) : 24 commit(workflow.nodes.concat([{ id: nextNodeId(workflow.nodes), name: bot.name, botId: bot.id, role: bot.role ?? '', x, y }]), edges) } setGesture(null) @@ -613,7 +658,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', 'data-fit': 'drawer', 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' }, @@ -634,14 +679,14 @@ 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('svg', { className: 'wf-wires', 'aria-hidden': true, ...wireSvgProps(height) }, + h('div', { className: 'wf-plane', 'data-plane': '1', style: { height, width: plane.width } }, + h('svg', { className: 'wf-wires', 'aria-hidden': true, ...wireSvgProps(height, plane.width) }, edges.map(edge => { - const from = nodes.find(node => node.id === edge.from) - const to = nodes.find(node => node.id === edge.to) + const from = fitted.get(edge.from) + const to = fitted.get(edge.to) if (!from || !to) return null return h('g', { key: `${edge.from}>${edge.to}` }, - h('path', { d: wirePath(from, to) }), + h('path', { d: wirePath(from, to, 0) }), editing ? h('circle', { cx: to.x, cy: to.y, r: 7, className: 'wf-end', 'data-port': 'end', 'data-src': edge.from, 'data-dst': edge.to, @@ -649,39 +694,42 @@ window.__ModuleLoader__.load({ }) : null) })), (workflow.layers ?? []).filter(layer => layer.length > 1).map(layer => { - const members = layer.map(id => nodes.find(node => node.id === id)).filter(Boolean) + const members = layer.map(id => fitted.get(id)).filter(Boolean) if (members.length < 2) return null return h('div', { key: layer.join('+'), className: 'wf-par wf-abs', style: parallelFrameStyle(members), }, h('div', { className: 'wf-plabel' }, tr('Parallel branches · Merge after all pass'))) }), - nodes.map(node => h('div', { - key: node.id, - 'data-node': node.id, - className: `wf-node wf-abs ${status[node.id] ?? 'queued'} ${selectedNode === node.id ? 'selected' : ''}`, - style: { left: (node.x ?? 0) - 84, top: node.y ?? 0 }, - onClick: () => editing ? setSelectedNode(node.id) : null, - onPointerDown: (event) => { - if (!editing || event.target?.dataset?.port || event.target?.dataset?.remove) return - setGesture({ kind: 'move', id: node.id, ox: event.clientX, oy: event.clientY, x: node.x, y: node.y }) - }, - onMouseEnter: editing ? (event) => onTip?.({ x: event.clientX, y: event.clientY, node }) : null, - onMouseLeave: editing ? () => onTip?.(null) : null, - }, - editing ? h('button', { - type: 'button', className: 'wf-port out', 'data-port': 'out', 'aria-label': node.name, - onPointerDown: (event) => { event.preventDefault?.(); event.stopPropagation?.(); setGesture({ kind: 'wire', from: node.id }) }, - }) : null, - editing ? h('button', { - type: 'button', className: 'wf-mini', 'data-remove': node.id, 'aria-label': tr('Remove'), - onClick: (event) => { - event.stopPropagation?.() - commit(workflow.nodes.filter(item => item.id !== node.id), edges.filter(edge => edge.from !== node.id && edge.to !== node.id)) - setSelectedNode(null) + nodes.map(node => { + const spot = fitted.get(node.id) ?? node + return h('div', { + key: node.id, + 'data-node': node.id, + className: `wf-node wf-abs ${status[node.id] ?? 'queued'} ${selectedNode === node.id ? 'selected' : ''}`, + style: { left: spot.x - NODE_W / 2, top: spot.y }, + onClick: () => editing ? setSelectedNode(node.id) : null, + onPointerDown: (event) => { + if (!editing || event.target?.dataset?.port || event.target?.dataset?.remove) return + setGesture({ kind: 'move', id: node.id, ox: event.clientX, oy: event.clientY, x: node.x, y: node.y, scale }) }, - }, '×') : null, - h(FlowNode, { node, status: status[node.id] ?? 'queued', roster })))), + onMouseEnter: editing ? (event) => onTip?.({ x: event.clientX, y: event.clientY, node }) : null, + onMouseLeave: editing ? () => onTip?.(null) : null, + }, + editing ? h('button', { + type: 'button', className: 'wf-port out', 'data-port': 'out', 'aria-label': node.name, + onPointerDown: (event) => { event.preventDefault?.(); event.stopPropagation?.(); setGesture({ kind: 'wire', from: node.id }) }, + }) : null, + editing ? h('button', { + type: 'button', className: 'wf-mini', 'data-remove': node.id, 'aria-label': tr('Remove'), + onClick: (event) => { + event.stopPropagation?.() + commit(workflow.nodes.filter(item => item.id !== node.id), edges.filter(edge => edge.from !== node.id && edge.to !== node.id)) + setSelectedNode(null) + }, + }, '×') : null, + 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' }, @@ -1510,7 +1558,7 @@ window.__ModuleLoader__.load({ .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-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} +.wf-dr-body{flex:1;overflow:auto;overflow-x:hidden;padding:4px 18px 20px} .wf-dr-title{display:flex;flex-direction:column;align-items:center;gap:4px;margin-bottom:10px} .wf-dr-title h2{font-size:16px;font-weight:600;margin:0} .wf-dr-title p{font-size:12px;color:var(--bt-ink-2,rgba(20,20,20,.6));text-align:center;line-height:1.5;margin:0;max-width:100%;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden} @@ -1522,6 +1570,10 @@ window.__ModuleLoader__.load({ .wf-chip-inline{margin-left:6px;font-size:9.5px;vertical-align:middle;font-weight:500} .wf-term.on{color:#16a34a;background:rgba(22,163,74,.1);border-color:transparent} .wf-canvas{display:flex;flex-direction:column;align-items:center;gap:8px;min-height:440px;position:relative;overflow:auto} +.wf-canvas[data-fit="drawer"]{min-height:0;align-items:stretch;overflow:hidden} +.wf-canvas[data-fit="drawer"] .wf-plane{width:100%;margin:2px 0 0} +.wf-canvas[data-fit="drawer"] .wf-wires{left:0;width:100%;overflow:visible} +.wf-canvas[data-fit="drawer"] .wf-term{white-space:normal;overflow:visible;text-overflow:clip;text-align:center;line-height:1.35;align-self:center;max-width:100%} .wf-palette{display:flex;flex-direction:column;align-items:stretch;gap:10px;width:min(100%,360px);margin:0 auto 4px;padding:10px 12px;border:1px dashed var(--bt-line,rgba(20,20,20,.14));border-radius:12px;background:var(--bt-card,#fff)} .wf-palette-hint{margin:0;font-size:11.5px;color:var(--bt-ink-2,rgba(20,20,20,.6));text-align:center;line-height:1.4} .wf-palette-row{display:flex;flex-wrap:wrap;gap:8px;justify-content:center} @@ -1590,7 +1642,7 @@ window.__ModuleLoader__.load({ .wf-assign-row{opacity:.9} .wf-par{position:relative;z-index:0;pointer-events:none;border:1px dashed var(--bt-ink-3,#9a9a9a);border-radius:14px;padding:16px 8px 8px} .wf-node.wf-abs{z-index:1} -.wf-plabel{position:absolute;top:-9px;left:50%;transform:translateX(-50%);font-size:10.5px;color:var(--bt-ink-2,rgba(20,20,20,.6));background:var(--bt-card,#fff);padding:0 6px;white-space:nowrap} +.wf-plabel{position:absolute;top:-9px;left:50%;transform:translateX(-50%);font-size:10.5px;color:var(--bt-ink-2,rgba(20,20,20,.6));background:var(--bt-card,#fff);padding:0 6px;white-space:normal;text-align:center;line-height:1.25;max-width:calc(100% - 16px)} .wf-settings-canvas{padding:16px} .wf-settings-layout{display:flex;flex-direction:column;gap:16px} .wf-settings-header{display:flex;gap:12px;align-items:center;flex-wrap:wrap} @@ -1629,7 +1681,8 @@ window.__ModuleLoader__.load({ return { name: 'flybot-workflow', inject: ['flybot'], apply, Section, WorkflowSettings, tr, columnOf, shortId, stageMessages, taskSummary, wirePath, WIRE_SPAN, NODE_WIRE_OUT, - runHistoryRows, liveStageRuns, resolveFocusedRun, parallelFrameStyle, NODE_W, + runHistoryRows, liveStageRuns, resolveFocusedRun, parallelFrameStyle, fitPlane, + DRAWER_FLOW_W, NODE_W, NODE_H, botPaletteSubtitle, botTint, settledCall, } }, diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index c93814c65..e21fa68ad 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -225,17 +225,18 @@ test('the sidebar opens the flowchart, and a drag, a wire and a dropped Bot are 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] + let canvas = findAll(view.tree, node => String(node.props?.className ?? '').split(' ').includes('wf-canvas'))[0] canvas.props.onPointerMove({ clientX: 58, clientY: 44 }) - canvas = findAll(view.tree, node => node.props?.className === 'wf-canvas')[0] + canvas = findAll(view.tree, node => String(node.props?.className ?? '').split(' ').includes('wf-canvas'))[0] canvas.props.onPointerUp({ clientX: 58, clientY: 44, target: { dataset: {} } }) + const fit = client.fitPlane(graph.nodes, client.DRAWER_FLOW_W) 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 / fit.scale)) + assert.equal(moved.body.nodes.find(node => node.id === 'coder').y, 260 + 24) 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' } } }) - canvas = findAll(view.tree, node => node.props?.className === 'wf-canvas')[0] + canvas = findAll(view.tree, node => String(node.props?.className ?? '').split(' ').includes('wf-canvas'))[0] canvas.props.onPointerUp({ target: { dataset: { node: 'tester' }, closest() { return null } } }) const wired = calls.filter(item => item.name === 'workflow/graph').at(-1) assert.ok(wired.body.edges.some(edge => edge.from === 'writer' && edge.to === 'tester')) @@ -244,13 +245,13 @@ test('the sidebar opens the flowchart, and a drag, a wire and a dropped Bot are assert.ok(docs) assert.equal(findAll(view.tree, node => node.props?.['data-bot'] === 'bot-friend').length, 0) docs.props.onPointerDown({ preventDefault() {}, target: { dataset: { bot: 'bot-docs' } } }) - canvas = findAll(view.tree, node => node.props?.className === 'wf-canvas')[0] + canvas = findAll(view.tree, node => String(node.props?.className ?? '').split(' ').includes('wf-canvas'))[0] canvas.props.onPointerUp({ target: { dataset: { plane: '1' } }, offsetX: 30, offsetY: 40 }) 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 - fit.offsetX) / fit.scale)) + assert.equal(seat.y, Math.round(40 - fit.offsetY)) button(view.tree, 'New').props.onClick() const template = findAll(view.tree, node => node.type === 'button' && node.props.className === 'wf-btn' && textOf(node) === '发布交付流水线')[0] @@ -894,6 +895,88 @@ test('parallel branch frames wrap node bounds without hard-coded width', () => { assert.equal(frame.left, 80 - client.NODE_W / 2 - 10) }) +test('the drawer fits every node and edge inside a 400px panel', () => { + const graph = delivery() + const panel = client.DRAWER_FLOW_W + assert.equal(panel, 364, 'the drawer Flow content box is 400px minus two 18px paddings') + const plane = client.fitPlane(graph.nodes, panel) + assert.equal(plane.width, panel) + assert.ok(plane.scale > 0 && plane.scale <= 1, 'the fit never enlarges the graph') + for (const node of plane.nodes) { + const left = node.x - client.NODE_W / 2 + const right = node.x + client.NODE_W / 2 + assert.ok(left >= 0 && right <= panel, `${node.id} box ${left}..${right} is outside 0..${panel}`) + } + for (const layer of graph.layers.filter(layer => layer.length > 1)) { + const members = layer.map(id => plane.nodes.find(node => node.id === id)) + const frame = client.parallelFrameStyle(members) + assert.ok(frame.left >= 0 && frame.left + frame.width <= panel, `${layer.join('+')} frame ${frame.left}..${frame.left + frame.width} is outside 0..${panel}`) + } + assert.ok(plane.scale < 1, 'GAP_X=188 parallel columns cannot fit a 364px panel at scale 1') + for (const edge of graph.edges) { + const from = plane.nodes.find(node => node.id === edge.from) + const to = plane.nodes.find(node => node.id === edge.to) + for (const point of [{ x: from.x, y: from.y + client.NODE_WIRE_OUT }, { x: to.x, y: to.y }]) { + assert.ok(point.x >= 0 && point.x <= panel, `${edge.from}>${edge.to} leaves the plane at x=${point.x}`) + assert.ok(point.y >= 0 && point.y <= plane.height) + } + } +}) + +test('fitted wire endpoints land on the node anchors and stay inside the plane', () => { + const graph = delivery() + const plane = client.fitPlane(graph.nodes, client.DRAWER_FLOW_W) + const at = id => plane.nodes.find(node => node.id === id) + const edges = graph.edges.concat([{ from: 'deployer', to: 'planner', loop: true, max: 2 }]) + for (const edge of edges) { + const from = at(edge.from) + const to = at(edge.to) + const d = client.wirePath(from, to, 0) + assert.ok(d.startsWith(`M ${from.x} ${from.y + client.NODE_WIRE_OUT} `), `${edge.from}>${edge.to} did not leave the source bottom anchor: ${d}`) + const numbers = d.match(/-?\d+(?:\.\d+)?/g).map(Number) + assert.equal(numbers.at(-2), to.x, `${edge.from}>${edge.to} did not land on the target x anchor`) + assert.equal(numbers.at(-1), to.y, `${edge.from}>${edge.to} did not land on the target y anchor`) + assert.ok(numbers.filter((_, i) => i % 2 === 0).every(x => x >= 0 && x <= plane.width), `${edge.from}>${edge.to} leaves the fitted width`) + assert.ok(numbers.filter((_, i) => i % 2 === 1).every(y => y >= 0 && y <= plane.height), `${edge.from}>${edge.to} leaves the fitted height`) + } +}) + +test('the drawer canvas does not scroll sideways and its labels wrap', () => { + const source = readFileSync(new URL('../client.js', import.meta.url), 'utf8') + assert.match(source, /\.wf-canvas\[data-fit="drawer"\]\{[^}]*overflow:hidden/, 'the fitted drawer canvas still scrolls') + assert.match(source, /\.wf-dr-body\{[^}]*overflow:auto/, 'the drawer body lost its single vertical scroll') + const label = source.match(/\.wf-plabel\{[^}]*\}/)[0] + assert.equal(label.includes('white-space:nowrap'), false, 'the parallel label is still clipped at the panel edge') + assert.match(label, /white-space:normal/) + const term = source.match(/\.wf-canvas\[data-fit="drawer"\] \.wf-term\{[^}]*\}/)[0] + assert.match(term, /white-space:normal/) + assert.equal(term.includes('text-overflow:ellipsis'), false) +}) + +test('the drawer Flow mounts the fitted narrow canvas and Settings keeps the scrolling one', async () => { + const graph = delivery() + const call = async (name) => { + if (name === 'workflow/state') return { workflows: [graph], templates: [] } + return { ok: true, workflows: [graph] } + } + const section = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + await settle() + findAll(section.tree, node => node.type === 'button' && textOf(node).includes('发布交付流水线'))[0].props.onClick() + button(section.tree, 'Flow').props.onClick() + const narrow = findAll(section.tree, node => node.props?.['data-fit'] === 'drawer') + assert.equal(narrow.length, 1, 'the drawer Flow canvas is not the fitted variant') + assert.equal(narrow[0].props['data-fit'], 'drawer') + const plane = findAll(section.tree, node => node.props?.['data-plane'] === '1')[0] + assert.ok(plane.props.style.width > 0 && plane.props.style.width <= client.DRAWER_FLOW_W) + section.unmount() + + const settings = mount(client.WorkflowSettings, { call, roster: { bots: [] } }) + await settle() + assert.equal(findAll(settings.tree, node => node.props?.['data-fit'] === 'drawer').length, 0) + assert.equal(findAll(settings.tree, node => node.props?.className === 'wf-canvas-view').length, 1) + settings.unmount() +}) + test('the workflow stage mounts no message composer', async () => { const graph = delivery() const prevFlybot = globalThis.__flybot__