From c81ea6f83b4adde29947ce099cc4ac0d0892d14d Mon Sep 17 00:00:00 2001 From: David Kaya Date: Tue, 24 Mar 2026 23:27:43 +0100 Subject: [PATCH] feat: improve graph readability with colored edges, bezier curves, and auto-layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Color-code edges by semantic role: structural edges (system↔node) in muted zinc, agent-to-agent edges in indigo for clear visual separation - Switch from smoothstep to bezier curves for smoother, more natural edge routing that reduces right-angle overlaps - Stagger agent nodes at alternating X offsets in concurrent, handoff, and group-chat layouts so parallel fan-out/fan-in edges take distinct bezier paths instead of stacking - Increase spacing in all layouts to give edges more room - Add dagre-powered auto-layout button (top-right of canvas) that computes optimal left-to-right hierarchical node positions - Add 2 new tests for auto-layout across all orchestration modes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bun.lock | 5 ++ package.json | 1 + .../pattern-graph/PatternGraphCanvas.tsx | 40 ++++++++- src/renderer/lib/patternGraph.ts | 86 ++++++++++++++++--- src/shared/domain/pattern.ts | 46 ++++++---- tests/renderer/patternGraph.test.ts | 33 +++++++ 6 files changed, 179 insertions(+), 32 deletions(-) diff --git a/bun.lock b/bun.lock index bf6222e..05313a5 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "kopaya", "dependencies": { + "@dagrejs/dagre": "^3.0.0", "@xyflow/react": "^12.10.1", "keytar": "^7.9.0", "lucide-react": "^0.577.0", @@ -71,6 +72,10 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@dagrejs/dagre": ["@dagrejs/dagre@3.0.0", "", { "dependencies": { "@dagrejs/graphlib": "4.0.1" } }, "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q=="], + + "@dagrejs/graphlib": ["@dagrejs/graphlib@4.0.1", "", {}, "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA=="], + "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], diff --git a/package.json b/package.json index 8e0b8b6..b67941e 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "vite": "7.1.10" }, "dependencies": { + "@dagrejs/dagre": "^3.0.0", "@xyflow/react": "^12.10.1", "keytar": "^7.9.0", "lucide-react": "^0.577.0", diff --git a/src/renderer/components/pattern-graph/PatternGraphCanvas.tsx b/src/renderer/components/pattern-graph/PatternGraphCanvas.tsx index 9197ee0..9022e40 100644 --- a/src/renderer/components/pattern-graph/PatternGraphCanvas.tsx +++ b/src/renderer/components/pattern-graph/PatternGraphCanvas.tsx @@ -1,11 +1,14 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import { ReactFlow, + ReactFlowProvider, Background, BackgroundVariant, + Panel, MarkerType, useNodesState, useEdgesState, + useReactFlow, type Node, type Edge, type OnConnect, @@ -13,12 +16,14 @@ import { type OnNodesChange, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; +import { LayoutGrid } from 'lucide-react'; import type { OrchestrationMode, PatternDefinition, PatternGraph } from '@shared/domain/pattern'; import { resolvePatternGraph } from '@shared/domain/pattern'; import type { ModelDefinition } from '@shared/domain/models'; import { addHandoffEdge, + autoLayoutGraph, fromCanvasPositions, isConnectionAllowed, isEdgeDeletionAllowed, @@ -38,13 +43,14 @@ interface PatternGraphCanvasProps { selectedNodeId: string | null; } -export function PatternGraphCanvas({ +function PatternGraphCanvasInner({ pattern, availableModels, onGraphChange, onNodeSelect, selectedNodeId, }: PatternGraphCanvasProps) { + const { fitView } = useReactFlow(); const graph = useMemo(() => resolvePatternGraph(pattern), [pattern]); const draggingRef = useRef(false); @@ -144,6 +150,13 @@ export function PatternGraphCanvas({ onNodeSelect(null); }, [onNodeSelect]); + const handleAutoLayout = useCallback(() => { + const layouted = autoLayoutGraph(graph); + onGraphChange(layouted); + // Allow React to render the new positions before fitting + requestAnimationFrame(() => fitView({ padding: 0.3 })); + }, [graph, onGraphChange, fitView]); + return (
+ + +
); } + +export function PatternGraphCanvas(props: PatternGraphCanvasProps) { + return ( + + + + ); +} diff --git a/src/renderer/lib/patternGraph.ts b/src/renderer/lib/patternGraph.ts index 802eedb..980b53a 100644 --- a/src/renderer/lib/patternGraph.ts +++ b/src/renderer/lib/patternGraph.ts @@ -115,17 +115,37 @@ function isEdgeDeletable(edge: PatternGraphEdge, mode: OrchestrationMode, graph: return sourceNode?.kind === 'agent' && targetNode?.kind === 'agent'; } +const EDGE_COLORS = { + structural: { stroke: '#3f3f46', markerColor: '#3f3f46' }, // zinc-700 — muted + agentToAgent: { stroke: '#6366f1', markerColor: '#6366f1' }, // indigo-500 — distinct +}; + +function resolveEdgeColor(edge: PatternGraphEdge, graph: PatternGraph): typeof EDGE_COLORS.structural { + const sourceNode = graph.nodes.find((n) => n.id === edge.source); + const targetNode = graph.nodes.find((n) => n.id === edge.target); + + if (sourceNode?.kind === 'agent' && targetNode?.kind === 'agent') { + return EDGE_COLORS.agentToAgent; + } + + return EDGE_COLORS.structural; +} + export function toCanvasEdges(graph: PatternGraph, mode: OrchestrationMode): Edge[] { - return graph.edges.map((edge) => ({ - id: edge.id, - source: edge.source, - target: edge.target, - type: 'smoothstep', - animated: mode === 'handoff', - deletable: isEdgeDeletable(edge, mode, graph), - markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: '#52525b' }, - style: { stroke: '#52525b', strokeWidth: 1.5 }, - })); + return graph.edges.map((edge) => { + const color = resolveEdgeColor(edge, graph); + + return { + id: edge.id, + source: edge.source, + target: edge.target, + type: 'default', + animated: mode === 'handoff', + deletable: isEdgeDeletable(edge, mode, graph), + markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: color.markerColor }, + style: { stroke: color.stroke, strokeWidth: 1.5 }, + }; + }); } export function fromCanvasPositions( @@ -324,3 +344,49 @@ export function findAgentForNode( return agents.find((a) => a.id === node.agentId); } + +/* ── Auto-layout via dagre ─────────────────────────────────── */ + +import dagre from '@dagrejs/dagre'; + +const NODE_WIDTH = 170; +const NODE_HEIGHT = 52; + +/** + * Re-compute all node positions using dagre's layered layout algorithm. + * Direction is always left-to-right to match the graph flow. + * Returns a new graph with updated positions; edges are unchanged. + */ +export function autoLayoutGraph(graph: PatternGraph): PatternGraph { + const g = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); + g.setGraph({ + rankdir: 'LR', + nodesep: 60, + ranksep: 120, + marginx: 20, + marginy: 20, + }); + + for (const node of graph.nodes) { + g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT }); + } + + for (const edge of graph.edges) { + g.setEdge(edge.source, edge.target); + } + + dagre.layout(g); + + const layoutedNodes = graph.nodes.map((node) => { + const dagreNode = g.node(node.id); + return { + ...node, + position: { + x: Math.round(dagreNode.x - NODE_WIDTH / 2), + y: Math.round(dagreNode.y - NODE_HEIGHT / 2), + }, + }; + }); + + return { nodes: layoutedNodes, edges: graph.edges }; +} diff --git a/src/shared/domain/pattern.ts b/src/shared/domain/pattern.ts index 03e25ba..833c7f0 100644 --- a/src/shared/domain/pattern.ts +++ b/src/shared/domain/pattern.ts @@ -140,6 +140,7 @@ function spreadY(index: number, count: number, gap = 170): number { } function createLinearGraph(agents: PatternAgentDefinition[]): PatternGraph { + const xStep = 250; const inputNode: PatternGraphNode = { id: SYSTEM_NODE_IDS.userInput, kind: 'user-input', @@ -148,10 +149,10 @@ function createLinearGraph(agents: PatternAgentDefinition[]): PatternGraph { const outputNode: PatternGraphNode = { id: SYSTEM_NODE_IDS.userOutput, kind: 'user-output', - position: { x: 220 * Math.max(agents.length + 1, 2), y: 0 }, + position: { x: xStep * Math.max(agents.length + 1, 2), y: 0 }, }; const agentNodes = agents.map((agent, index) => - createAgentNode(agent, index, { x: 220 * (index + 1), y: 0 }), + createAgentNode(agent, index, { x: xStep * (index + 1), y: 0 }), ); const edges: PatternGraphEdge[] = []; const path = [inputNode.id, ...agentNodes.map((node) => node.id), outputNode.id]; @@ -166,6 +167,8 @@ function createLinearGraph(agents: PatternAgentDefinition[]): PatternGraph { } function createConcurrentGraph(agents: PatternAgentDefinition[]): PatternGraph { + const agentCount = Math.max(agents.length, 1); + const agentGap = 150; const inputNode: PatternGraphNode = { id: SYSTEM_NODE_IDS.userInput, kind: 'user-input', @@ -174,20 +177,25 @@ function createConcurrentGraph(agents: PatternAgentDefinition[]): PatternGraph { const distributorNode: PatternGraphNode = { id: SYSTEM_NODE_IDS.distributor, kind: 'distributor', - position: { x: 190, y: 0 }, + position: { x: 200, y: 0 }, }; const collectorNode: PatternGraphNode = { id: SYSTEM_NODE_IDS.collector, kind: 'collector', - position: { x: 650, y: 0 }, + position: { x: 700, y: 0 }, }; const outputNode: PatternGraphNode = { id: SYSTEM_NODE_IDS.userOutput, kind: 'user-output', - position: { x: 860, y: 0 }, + position: { x: 920, y: 0 }, }; + // Stagger agents at alternating X offsets so fan-out/fan-in edges + // take distinct bezier paths and don't overlap each other. const agentNodes = agents.map((agent, index) => - createAgentNode(agent, index, { x: 430, y: spreadY(index, Math.max(agents.length, 1), 170) }), + createAgentNode(agent, index, { + x: index % 2 === 0 ? 420 : 480, + y: spreadY(index, agentCount, agentGap), + }), ); return { @@ -210,19 +218,19 @@ function createHandoffGraph(agents: PatternAgentDefinition[]): PatternGraph { const outputNode: PatternGraphNode = { id: SYSTEM_NODE_IDS.userOutput, kind: 'user-output', - position: { x: 700, y: 0 }, + position: { x: 780, y: 0 }, }; const entryAgent = agents[0]; const specialistCount = Math.max(agents.length - 1, 1); const entryNode = entryAgent - ? createAgentNode(entryAgent, 0, { x: 200, y: 0 }) + ? createAgentNode(entryAgent, 0, { x: 220, y: 0 }) : undefined; - // Place specialists in a vertical column with enough spacing so - // bidirectional edges to/from the triage agent don't overlap. + // Place specialists in a staggered column with wider horizontal and vertical + // spacing so bidirectional bezier edges between triage↔specialists route cleanly. const specialistNodes = agents.slice(1).map((agent, index) => createAgentNode(agent, index + 1, { - x: 460, - y: spreadY(index, specialistCount, 150), + x: index % 2 === 0 ? 500 : 560, + y: spreadY(index, specialistCount, 160), }), ); const nodes = [inputNode, ...(entryNode ? [entryNode] : []), ...specialistNodes, outputNode]; @@ -243,6 +251,7 @@ function createHandoffGraph(agents: PatternAgentDefinition[]): PatternGraph { } function createGroupChatGraph(agents: PatternAgentDefinition[]): PatternGraph { + const agentCount = Math.max(agents.length, 1); const inputNode: PatternGraphNode = { id: SYSTEM_NODE_IDS.userInput, kind: 'user-input', @@ -251,19 +260,20 @@ function createGroupChatGraph(agents: PatternAgentDefinition[]): PatternGraph { const orchestratorNode: PatternGraphNode = { id: SYSTEM_NODE_IDS.orchestrator, kind: 'orchestrator', - position: { x: 200, y: 0 }, + position: { x: 220, y: 0 }, }; const outputNode: PatternGraphNode = { id: SYSTEM_NODE_IDS.userOutput, kind: 'user-output', - position: { x: 660, y: 0 }, + position: { x: 740, y: 0 }, }; - // Place agents in a vertical column to the right of the orchestrator. - // This avoids crossing edges from the bidirectional orchestrator↔agent links. + // Place agents in a staggered column to the right of the orchestrator. + // Alternating X offsets give bezier curves distinct paths so bidirectional + // orchestrator↔agent edges don't stack on top of each other. const agentNodes = agents.map((agent, index) => createAgentNode(agent, index, { - x: 440, - y: spreadY(index, Math.max(agents.length, 1), 130), + x: index % 2 === 0 ? 460 : 520, + y: spreadY(index, agentCount, 140), }), ); diff --git a/tests/renderer/patternGraph.test.ts b/tests/renderer/patternGraph.test.ts index b170563..7d2c57c 100644 --- a/tests/renderer/patternGraph.test.ts +++ b/tests/renderer/patternGraph.test.ts @@ -4,6 +4,7 @@ import { createBuiltinPatterns, resolvePatternGraph, type PatternDefinition } fr import { addAgentNodeToGraph, addHandoffEdge, + autoLayoutGraph, canMoveSequential, findAgentForNode, isConnectionAllowed, @@ -301,3 +302,35 @@ describe('edge deletion rules', () => { } }); }); + +describe('auto-layout', () => { + test('autoLayoutGraph repositions nodes without changing edges', () => { + const pattern = findPattern('handoff'); + const graph = resolvePatternGraph(pattern); + const layouted = autoLayoutGraph(graph); + + expect(layouted.nodes.length).toBe(graph.nodes.length); + expect(layouted.edges.length).toBe(graph.edges.length); + expect(layouted.edges).toEqual(graph.edges); + + const positionsChanged = layouted.nodes.some((n, i) => { + const orig = graph.nodes[i]!; + return n.position.x !== orig.position.x || n.position.y !== orig.position.y; + }); + expect(positionsChanged).toBe(true); + }); + + test('autoLayoutGraph produces finite positions for all modes', () => { + for (const mode of ['sequential', 'concurrent', 'handoff', 'group-chat'] as const) { + const pattern = findPattern(mode); + const graph = resolvePatternGraph(pattern); + const layouted = autoLayoutGraph(graph); + + expect(layouted.nodes.length).toBe(graph.nodes.length); + for (const node of layouted.nodes) { + expect(Number.isFinite(node.position.x)).toBe(true); + expect(Number.isFinite(node.position.y)).toBe(true); + } + } + }); +});