feat: improve graph readability with colored edges, bezier curves, and auto-layout

- 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>
This commit is contained in:
David Kaya
2026-03-24 23:27:43 +01:00
co-authored by Copilot
parent 9b689e1411
commit c81ea6f83b
6 changed files with 179 additions and 32 deletions
+5
View File
@@ -5,6 +5,7 @@
"": { "": {
"name": "kopaya", "name": "kopaya",
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^3.0.0",
"@xyflow/react": "^12.10.1", "@xyflow/react": "^12.10.1",
"keytar": "^7.9.0", "keytar": "^7.9.0",
"lucide-react": "^0.577.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=="], "@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=="], "@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=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
+1
View File
@@ -46,6 +46,7 @@
"vite": "7.1.10" "vite": "7.1.10"
}, },
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^3.0.0",
"@xyflow/react": "^12.10.1", "@xyflow/react": "^12.10.1",
"keytar": "^7.9.0", "keytar": "^7.9.0",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
@@ -1,11 +1,14 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useCallback, useEffect, useMemo, useRef } from 'react';
import { import {
ReactFlow, ReactFlow,
ReactFlowProvider,
Background, Background,
BackgroundVariant, BackgroundVariant,
Panel,
MarkerType, MarkerType,
useNodesState, useNodesState,
useEdgesState, useEdgesState,
useReactFlow,
type Node, type Node,
type Edge, type Edge,
type OnConnect, type OnConnect,
@@ -13,12 +16,14 @@ import {
type OnNodesChange, type OnNodesChange,
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import { LayoutGrid } from 'lucide-react';
import type { OrchestrationMode, PatternDefinition, PatternGraph } from '@shared/domain/pattern'; import type { OrchestrationMode, PatternDefinition, PatternGraph } from '@shared/domain/pattern';
import { resolvePatternGraph } from '@shared/domain/pattern'; import { resolvePatternGraph } from '@shared/domain/pattern';
import type { ModelDefinition } from '@shared/domain/models'; import type { ModelDefinition } from '@shared/domain/models';
import { import {
addHandoffEdge, addHandoffEdge,
autoLayoutGraph,
fromCanvasPositions, fromCanvasPositions,
isConnectionAllowed, isConnectionAllowed,
isEdgeDeletionAllowed, isEdgeDeletionAllowed,
@@ -38,13 +43,14 @@ interface PatternGraphCanvasProps {
selectedNodeId: string | null; selectedNodeId: string | null;
} }
export function PatternGraphCanvas({ function PatternGraphCanvasInner({
pattern, pattern,
availableModels, availableModels,
onGraphChange, onGraphChange,
onNodeSelect, onNodeSelect,
selectedNodeId, selectedNodeId,
}: PatternGraphCanvasProps) { }: PatternGraphCanvasProps) {
const { fitView } = useReactFlow();
const graph = useMemo(() => resolvePatternGraph(pattern), [pattern]); const graph = useMemo(() => resolvePatternGraph(pattern), [pattern]);
const draggingRef = useRef(false); const draggingRef = useRef(false);
@@ -144,6 +150,13 @@ export function PatternGraphCanvas({
onNodeSelect(null); onNodeSelect(null);
}, [onNodeSelect]); }, [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 ( return (
<div className="h-full w-full rounded-xl border border-zinc-800 bg-zinc-950/50"> <div className="h-full w-full rounded-xl border border-zinc-800 bg-zinc-950/50">
<ReactFlow <ReactFlow
@@ -164,15 +177,34 @@ export function PatternGraphCanvas({
maxZoom={2} maxZoom={2}
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
defaultEdgeOptions={{ defaultEdgeOptions={{
type: 'smoothstep', type: 'default',
style: { stroke: '#52525b', strokeWidth: 1.5 }, style: { stroke: '#6366f1', strokeWidth: 1.5 },
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: '#52525b' }, markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: '#6366f1' },
}} }}
connectionLineStyle={{ stroke: '#6366f1', strokeWidth: 1.5 }} connectionLineStyle={{ stroke: '#6366f1', strokeWidth: 1.5 }}
deleteKeyCode="Delete" deleteKeyCode="Delete"
> >
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#27272a" /> <Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#27272a" />
<Panel position="top-right">
<button
type="button"
onClick={handleAutoLayout}
className="flex items-center gap-1.5 rounded-lg border border-zinc-700 bg-zinc-800/90 px-2.5 py-1.5 text-[11px] font-medium text-zinc-300 shadow-sm backdrop-blur transition hover:border-zinc-600 hover:bg-zinc-700/90 hover:text-zinc-100"
title="Auto-layout nodes"
>
<LayoutGrid className="size-3.5" />
Auto layout
</button>
</Panel>
</ReactFlow> </ReactFlow>
</div> </div>
); );
} }
export function PatternGraphCanvas(props: PatternGraphCanvasProps) {
return (
<ReactFlowProvider>
<PatternGraphCanvasInner {...props} />
</ReactFlowProvider>
);
}
+76 -10
View File
@@ -115,17 +115,37 @@ function isEdgeDeletable(edge: PatternGraphEdge, mode: OrchestrationMode, graph:
return sourceNode?.kind === 'agent' && targetNode?.kind === 'agent'; 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[] { export function toCanvasEdges(graph: PatternGraph, mode: OrchestrationMode): Edge[] {
return graph.edges.map((edge) => ({ return graph.edges.map((edge) => {
id: edge.id, const color = resolveEdgeColor(edge, graph);
source: edge.source,
target: edge.target, return {
type: 'smoothstep', id: edge.id,
animated: mode === 'handoff', source: edge.source,
deletable: isEdgeDeletable(edge, mode, graph), target: edge.target,
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: '#52525b' }, type: 'default',
style: { stroke: '#52525b', strokeWidth: 1.5 }, 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( export function fromCanvasPositions(
@@ -324,3 +344,49 @@ export function findAgentForNode(
return agents.find((a) => a.id === node.agentId); 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 };
}
+28 -18
View File
@@ -140,6 +140,7 @@ function spreadY(index: number, count: number, gap = 170): number {
} }
function createLinearGraph(agents: PatternAgentDefinition[]): PatternGraph { function createLinearGraph(agents: PatternAgentDefinition[]): PatternGraph {
const xStep = 250;
const inputNode: PatternGraphNode = { const inputNode: PatternGraphNode = {
id: SYSTEM_NODE_IDS.userInput, id: SYSTEM_NODE_IDS.userInput,
kind: 'user-input', kind: 'user-input',
@@ -148,10 +149,10 @@ function createLinearGraph(agents: PatternAgentDefinition[]): PatternGraph {
const outputNode: PatternGraphNode = { const outputNode: PatternGraphNode = {
id: SYSTEM_NODE_IDS.userOutput, id: SYSTEM_NODE_IDS.userOutput,
kind: 'user-output', 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) => 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 edges: PatternGraphEdge[] = [];
const path = [inputNode.id, ...agentNodes.map((node) => node.id), outputNode.id]; 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 { function createConcurrentGraph(agents: PatternAgentDefinition[]): PatternGraph {
const agentCount = Math.max(agents.length, 1);
const agentGap = 150;
const inputNode: PatternGraphNode = { const inputNode: PatternGraphNode = {
id: SYSTEM_NODE_IDS.userInput, id: SYSTEM_NODE_IDS.userInput,
kind: 'user-input', kind: 'user-input',
@@ -174,20 +177,25 @@ function createConcurrentGraph(agents: PatternAgentDefinition[]): PatternGraph {
const distributorNode: PatternGraphNode = { const distributorNode: PatternGraphNode = {
id: SYSTEM_NODE_IDS.distributor, id: SYSTEM_NODE_IDS.distributor,
kind: 'distributor', kind: 'distributor',
position: { x: 190, y: 0 }, position: { x: 200, y: 0 },
}; };
const collectorNode: PatternGraphNode = { const collectorNode: PatternGraphNode = {
id: SYSTEM_NODE_IDS.collector, id: SYSTEM_NODE_IDS.collector,
kind: 'collector', kind: 'collector',
position: { x: 650, y: 0 }, position: { x: 700, y: 0 },
}; };
const outputNode: PatternGraphNode = { const outputNode: PatternGraphNode = {
id: SYSTEM_NODE_IDS.userOutput, id: SYSTEM_NODE_IDS.userOutput,
kind: 'user-output', 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) => 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 { return {
@@ -210,19 +218,19 @@ function createHandoffGraph(agents: PatternAgentDefinition[]): PatternGraph {
const outputNode: PatternGraphNode = { const outputNode: PatternGraphNode = {
id: SYSTEM_NODE_IDS.userOutput, id: SYSTEM_NODE_IDS.userOutput,
kind: 'user-output', kind: 'user-output',
position: { x: 700, y: 0 }, position: { x: 780, y: 0 },
}; };
const entryAgent = agents[0]; const entryAgent = agents[0];
const specialistCount = Math.max(agents.length - 1, 1); const specialistCount = Math.max(agents.length - 1, 1);
const entryNode = entryAgent const entryNode = entryAgent
? createAgentNode(entryAgent, 0, { x: 200, y: 0 }) ? createAgentNode(entryAgent, 0, { x: 220, y: 0 })
: undefined; : undefined;
// Place specialists in a vertical column with enough spacing so // Place specialists in a staggered column with wider horizontal and vertical
// bidirectional edges to/from the triage agent don't overlap. // spacing so bidirectional bezier edges between triage↔specialists route cleanly.
const specialistNodes = agents.slice(1).map((agent, index) => const specialistNodes = agents.slice(1).map((agent, index) =>
createAgentNode(agent, index + 1, { createAgentNode(agent, index + 1, {
x: 460, x: index % 2 === 0 ? 500 : 560,
y: spreadY(index, specialistCount, 150), y: spreadY(index, specialistCount, 160),
}), }),
); );
const nodes = [inputNode, ...(entryNode ? [entryNode] : []), ...specialistNodes, outputNode]; const nodes = [inputNode, ...(entryNode ? [entryNode] : []), ...specialistNodes, outputNode];
@@ -243,6 +251,7 @@ function createHandoffGraph(agents: PatternAgentDefinition[]): PatternGraph {
} }
function createGroupChatGraph(agents: PatternAgentDefinition[]): PatternGraph { function createGroupChatGraph(agents: PatternAgentDefinition[]): PatternGraph {
const agentCount = Math.max(agents.length, 1);
const inputNode: PatternGraphNode = { const inputNode: PatternGraphNode = {
id: SYSTEM_NODE_IDS.userInput, id: SYSTEM_NODE_IDS.userInput,
kind: 'user-input', kind: 'user-input',
@@ -251,19 +260,20 @@ function createGroupChatGraph(agents: PatternAgentDefinition[]): PatternGraph {
const orchestratorNode: PatternGraphNode = { const orchestratorNode: PatternGraphNode = {
id: SYSTEM_NODE_IDS.orchestrator, id: SYSTEM_NODE_IDS.orchestrator,
kind: 'orchestrator', kind: 'orchestrator',
position: { x: 200, y: 0 }, position: { x: 220, y: 0 },
}; };
const outputNode: PatternGraphNode = { const outputNode: PatternGraphNode = {
id: SYSTEM_NODE_IDS.userOutput, id: SYSTEM_NODE_IDS.userOutput,
kind: 'user-output', 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. // Place agents in a staggered column to the right of the orchestrator.
// This avoids crossing edges from the bidirectional orchestrator↔agent links. // 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) => const agentNodes = agents.map((agent, index) =>
createAgentNode(agent, index, { createAgentNode(agent, index, {
x: 440, x: index % 2 === 0 ? 460 : 520,
y: spreadY(index, Math.max(agents.length, 1), 130), y: spreadY(index, agentCount, 140),
}), }),
); );
+33
View File
@@ -4,6 +4,7 @@ import { createBuiltinPatterns, resolvePatternGraph, type PatternDefinition } fr
import { import {
addAgentNodeToGraph, addAgentNodeToGraph,
addHandoffEdge, addHandoffEdge,
autoLayoutGraph,
canMoveSequential, canMoveSequential,
findAgentForNode, findAgentForNode,
isConnectionAllowed, 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);
}
}
});
});