Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1513.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions docs/subsystems/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
57 changes: 48 additions & 9 deletions plugins/workflow/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -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,
Expand All @@ -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 })
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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' },
Expand All @@ -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)
Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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}
Expand Down
59 changes: 55 additions & 4 deletions plugins/workflow/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} } })
Expand All @@ -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' } } })
Expand All @@ -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]
Expand All @@ -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).
Expand Down
Loading