Skip to content

Commit 6531c0f

Browse files
abossardCopilot
andcommitted
feat: friendlier workflow editor — connect mode, double-click add, dialog edges
- Connect Mode toggle button: click source node then target to draw edge (no shift key needed). Crosshair cursor + green '+' hint on target. - Double-click empty canvas area to add a node at that position - Node dialog now has 'Connect to…' section with buttons for each unconnected node — draw edges without touching the canvas - Add Node button opens config dialog immediately for the new node - Dynamic help text updates based on current mode - Escape key exits connect mode - Updated Playwright tests for new UX Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 13013e9 commit 6531c0f

3 files changed

Lines changed: 94 additions & 9 deletions

File tree

frontend/src/features/workflow/WorkflowPage.jsx

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
Add24Regular,
3535
ArrowReset24Regular,
3636
Dismiss24Regular,
37+
Link24Regular,
3738
Play24Regular,
3839
} from '@fluentui/react-icons'
3940
import { useCallback, useEffect, useRef, useState } from 'react'
@@ -151,7 +152,7 @@ function edgePt(node, tx, ty) {
151152
return { x: node.x + (dx / d) * (NODE_R + 2), y: node.y + (dy / d) * (NODE_R + 2) }
152153
}
153154

154-
function renderCanvas(ctx, canvas, nodes, edges, selectedId, hoveredId, connecting, mousePos, animProgress) {
155+
function renderCanvas(ctx, canvas, nodes, edges, selectedId, hoveredId, connecting, mousePos, animProgress, connectMode) {
155156
const dpr = window.devicePixelRatio || 1
156157
const W = canvas.width / dpr, H = canvas.height / dpr
157158
ctx.clearRect(0, 0, canvas.width, canvas.height)
@@ -211,6 +212,13 @@ function renderCanvas(ctx, canvas, nodes, edges, selectedId, hoveredId, connecti
211212
ctx.font = 'bold 12px Inter, system-ui, sans-serif'
212213
ctx.fillStyle = '#1e293b'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'
213214
lines.forEach((line, i) => ctx.fillText(line, node.x, node.y + NODE_R + 10 + i * 15))
215+
216+
// Connect mode: show a small "+" on hovered non-selected nodes
217+
if (connectMode && connecting && node.id !== connecting && isHov) {
218+
ctx.font = 'bold 18px Inter, system-ui, sans-serif'
219+
ctx.fillStyle = '#16a34a'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'
220+
ctx.fillText('+', node.x, node.y)
221+
}
214222
})
215223
ctx.restore()
216224
}
@@ -257,6 +265,8 @@ export default function WorkflowPage() {
257265
const [animProgress, setAnimProgress] = useState(null)
258266
const [nodeDialog, setNodeDialog] = useState(null)
259267
const [canvasReady, setCanvasReady] = useState(0)
268+
const [connectMode, setConnectMode] = useState(false)
269+
const dragStartPos = useRef(null) // track if mouse actually moved (to distinguish click vs drag)
260270

261271
const switchWf = (key) => {
262272
const wf = WORKFLOWS[key]; if (!wf) return
@@ -273,19 +283,64 @@ export default function WorkflowPage() {
273283
}, [])
274284

275285
useEffect(() => { resize(); window.addEventListener('resize', resize); return () => window.removeEventListener('resize', resize) }, [resize])
276-
useEffect(() => { const c = canvasRef.current; if (!c || !canvasReady) return; renderCanvas(c.getContext('2d'), c, nodes, edges, selectedId, hoveredId, connecting, mousePos, animProgress) }, [nodes, edges, selectedId, hoveredId, connecting, mousePos, animProgress, canvasReady])
286+
useEffect(() => { const c = canvasRef.current; if (!c || !canvasReady) return; renderCanvas(c.getContext('2d'), c, nodes, edges, selectedId, hoveredId, connecting, mousePos, animProgress, connectMode) }, [nodes, edges, selectedId, hoveredId, connecting, mousePos, animProgress, canvasReady, connectMode])
277287
useEffect(() => { if (!animating) { setAnimProgress(null); return }; let ok = true; const t = () => { if (!ok) return; setAnimProgress((p) => ((p || 0) + 0.003) % 1); animRef.current = requestAnimationFrame(t) }; animRef.current = requestAnimationFrame(t); return () => { ok = false; cancelAnimationFrame(animRef.current) } }, [animating])
278288

279289
const gp = (e) => { const r = canvasRef.current.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top } }
280290
const fn = (x, y) => { for (let i = nodes.length - 1; i >= 0; i--) if (pointInNode(x, y, nodes[i])) return nodes[i]; return null }
281291

282-
const onDown = (e) => { const p = gp(e), n = fn(p.x, p.y); if (n) { setSelectedId(n.id); e.shiftKey ? (setConnecting(n.id), setMousePos(p)) : setDragging({ id: n.id, ox: p.x - n.x, oy: p.y - n.y }) } else setSelectedId(null) }
292+
const onDown = (e) => {
293+
const p = gp(e), n = fn(p.x, p.y)
294+
dragStartPos.current = p
295+
if (n) {
296+
setSelectedId(n.id)
297+
if (connectMode || e.shiftKey) {
298+
setConnecting(n.id); setMousePos(p)
299+
} else {
300+
setDragging({ id: n.id, ox: p.x - n.x, oy: p.y - n.y })
301+
}
302+
} else {
303+
setSelectedId(null)
304+
}
305+
}
283306
const onMove = (e) => { const p = gp(e); setMousePos(p); dragging ? setNodes((prev) => prev.map((n) => n.id === dragging.id ? { ...n, x: p.x - dragging.ox, y: p.y - dragging.oy } : n)) : setHoveredId(fn(p.x, p.y)?.id || null) }
284-
const onUp = (e) => { if (connecting) { const t = fn(gp(e).x, gp(e).y); if (t && t.id !== connecting && !edges.some((x) => x.from === connecting && x.to === t.id)) setEdges((p) => [...p, { from: connecting, to: t.id }]); setConnecting(null) }; setDragging(null) }
285-
const onClick = (e) => { if (!dragging) { const n = fn(gp(e).x, gp(e).y); if (n) setNodeDialog(n.id) } }
286-
const onDbl = (e) => { const p = gp(e), n = fn(p.x, p.y); if (n) { const r = canvasRef.current.getBoundingClientRect(); setEditing({ nodeId: n.id, x: e.clientX - r.left, y: e.clientY - r.top }); setEditText(n.label.replace(/\n/g, ' ')) } }
307+
const onUp = (e) => {
308+
if (connecting) {
309+
const t = fn(gp(e).x, gp(e).y)
310+
if (t && t.id !== connecting && !edges.some((x) => x.from === connecting && x.to === t.id)) {
311+
setEdges((p) => [...p, { from: connecting, to: t.id }])
312+
}
313+
setConnecting(null)
314+
}
315+
setDragging(null)
316+
}
317+
const onClick = (e) => {
318+
// Only open dialog if mouse didn't move (not a drag)
319+
const p = gp(e)
320+
const start = dragStartPos.current
321+
if (start && Math.abs(p.x - start.x) + Math.abs(p.y - start.y) > 5) return
322+
if (!connectMode) {
323+
const n = fn(p.x, p.y)
324+
if (n) setNodeDialog(n.id)
325+
}
326+
}
327+
// Double-click: rename if on node, add new node if on empty canvas
328+
const onDbl = (e) => {
329+
const p = gp(e), n = fn(p.x, p.y)
330+
if (n) {
331+
const r = canvasRef.current.getBoundingClientRect()
332+
setEditing({ nodeId: n.id, x: e.clientX - r.left, y: e.clientY - r.top })
333+
setEditText(n.label.replace(/\n/g, ' '))
334+
} else {
335+
// Add new node where user double-clicked
336+
const id = 'n' + (_nid++)
337+
setNodes((prev) => [...prev, { id, x: p.x, y: p.y, label: 'New Step', color: '#475569', agent: 'none' }])
338+
setSelectedId(id)
339+
setNodeDialog(id)
340+
}
341+
}
287342
const commitEdit = () => { if (editing && editText.trim()) setNodes((p) => p.map((n) => n.id === editing.nodeId ? { ...n, label: editText.trim() } : n)); setEditing(null) }
288-
const addNode = () => { const id = 'n' + (_nid++); setNodes((p) => [...p, { id, x: 150 + Math.random() * 300, y: 150 + Math.random() * 150, label: 'New Step', color: '#475569', agent: 'none' }]); setSelectedId(id) }
343+
const addNode = () => { const id = 'n' + (_nid++); setNodes((p) => [...p, { id, x: 200 + Math.random() * 300, y: 100 + Math.random() * 250, label: 'New Step', color: '#475569', agent: 'none' }]); setSelectedId(id); setNodeDialog(id) }
289344
const delSel = () => { if (!selectedId) return; setNodes((p) => p.filter((n) => n.id !== selectedId)); setEdges((p) => p.filter((e) => e.from !== selectedId && e.to !== selectedId)); setSelectedId(null); setNodeDialog(null) }
290345

291346
const dNode = nodeDialog ? nodes.find((n) => n.id === nodeDialog) : null
@@ -296,10 +351,15 @@ export default function WorkflowPage() {
296351
<div className={styles.header}>
297352
<div>
298353
<Subtitle2>Support Workflow</Subtitle2>
299-
<Caption1 style={{ display: 'block', marginTop: '2px' }}>Click node to configure · Drag to move · Shift+drag to connect · Double-click to rename</Caption1>
354+
<Caption1 style={{ display: 'block', marginTop: '2px' }}>
355+
{connectMode
356+
? '🔗 Connect mode: click a node, then click another to draw an edge. Press Esc or toggle off to exit.'
357+
: 'Click node to configure · Drag to move · Double-click empty area to add node'}
358+
</Caption1>
300359
</div>
301360
<div className={styles.toolbar}>
302361
<Button appearance={animating ? 'primary' : 'outline'} icon={<Play24Regular />} onClick={() => setAnimating(!animating)} data-testid="workflow-animate">{animating ? 'Stop' : 'Animate'}</Button>
362+
<Button appearance={connectMode ? 'primary' : 'outline'} icon={<Link24Regular />} onClick={() => setConnectMode(!connectMode)} data-testid="workflow-connect-mode">{connectMode ? 'Connecting…' : 'Connect'}</Button>
303363
<Button appearance="subtle" icon={<Add24Regular />} onClick={addNode} data-testid="workflow-add-node">Add Node</Button>
304364
<Button appearance="subtle" icon={<ArrowReset24Regular />} onClick={() => switchWf(activeWf)} data-testid="workflow-reset">Reset</Button>
305365
</div>
@@ -319,7 +379,11 @@ export default function WorkflowPage() {
319379
<div className={styles.canvasWrap} ref={wrapRef}>
320380
<canvas ref={canvasRef} onMouseDown={onDown} onMouseMove={onMove} onMouseUp={onUp}
321381
onMouseLeave={() => { setDragging(null); setConnecting(null); setHoveredId(null) }}
322-
onClick={onClick} onDoubleClick={onDbl} style={{ display: 'block' }} data-testid="workflow-canvas" />
382+
onClick={onClick} onDoubleClick={onDbl}
383+
onKeyDown={(e) => { if (e.key === 'Escape') { setConnectMode(false); setConnecting(null) } }}
384+
tabIndex={0}
385+
style={{ display: 'block', cursor: connectMode ? 'crosshair' : 'default', outline: 'none' }}
386+
data-testid="workflow-canvas" />
323387
{editing && (
324388
<div className={styles.editOverlay} style={{ left: editing.x - 80, top: editing.y - 16 }}>
325389
<Input autoFocus value={editText} onChange={(_, d) => setEditText(d.value)}
@@ -368,6 +432,20 @@ export default function WorkflowPage() {
368432
))}
369433
</Combobox>
370434
</Field>
435+
<Field label="Connect to…">
436+
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
437+
{nodes.filter((n) => n.id !== dNode.id && !edges.some((e) => e.from === dNode.id && e.to === n.id)).map((n) => (
438+
<Button key={n.id} size="small" appearance="outline"
439+
onClick={() => { setEdges((p) => [...p, { from: dNode.id, to: n.id }]) }}
440+
data-testid={'connect-to-' + n.id}>
441+
{n.label.replace(/\n/g, ' ')}
442+
</Button>
443+
))}
444+
{nodes.filter((n) => n.id !== dNode.id && !edges.some((e) => e.from === dNode.id && e.to === n.id)).length === 0 && (
445+
<Caption1>Already connected to all nodes</Caption1>
446+
)}
447+
</div>
448+
</Field>
371449
{dNode.agent && dNode.agent !== 'none' && (
372450
<Badge appearance="filled" color="brand" style={{ alignSelf: 'flex-start' }}>
373451
{'🤖 ' + (AGENT_PRESETS.find((a) => a.id === dNode.agent)?.name || '')}

tests/e2e/menu-screenshots.spec.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ test("menu page screenshots", async ({ page }, testInfo) => {
1111
{ name: "fields", tab: "tab-fields" },
1212
{ name: "agent-fabric", tab: "tab-workbench" },
1313
{ name: "agent", tab: "tab-agent" },
14+
{ name: "activity", tab: "tab-activity" },
15+
{ name: "workflow", tab: "tab-workflow" },
16+
{ name: "settings", tab: "tab-settings" },
1417
];
1518

1619
await page.goto(pages[0].url);
@@ -22,6 +25,7 @@ test("menu page screenshots", async ({ page }, testInfo) => {
2225
for (const entry of pages.slice(1)) {
2326
await page.getByTestId(entry.tab).click();
2427
await expect(page.getByTestId(entry.tab)).toHaveAttribute("aria-selected", "true");
28+
await page.waitForTimeout(500); // let canvas / SSE settle
2529
await page.screenshot({
2630
path: testInfo.outputPath(`menu-${entry.name}.png`),
2731
fullPage: true,

tests/e2e/workflow.spec.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,15 @@ test.describe("Workflow page", () => {
4646
await visit(page, "/workflow");
4747
await expect(page.getByText("10 nodes")).toBeVisible();
4848
await page.getByTestId("workflow-add-node").click();
49+
await page.getByText("Done").click();
4950
await expect(page.getByText("11 nodes")).toBeVisible();
5051
});
5152

5253
test("reset restores default workflow", async ({ page }) => {
5354
await visit(page, "/workflow");
5455
await page.getByTestId("workflow-add-node").click();
56+
// Close dialog that opens for the new node
57+
await page.getByText("Done").click();
5558
await expect(page.getByText("11 nodes")).toBeVisible();
5659
await page.getByTestId("workflow-reset").click();
5760
await expect(page.getByText("10 nodes")).toBeVisible();

0 commit comments

Comments
 (0)