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",
"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=="],
+1
View File
@@ -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",
@@ -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 (
<div className="h-full w-full rounded-xl border border-zinc-800 bg-zinc-950/50">
<ReactFlow
@@ -164,15 +177,34 @@ export function PatternGraphCanvas({
maxZoom={2}
proOptions={{ hideAttribution: true }}
defaultEdgeOptions={{
type: 'smoothstep',
style: { stroke: '#52525b', strokeWidth: 1.5 },
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: '#52525b' },
type: 'default',
style: { stroke: '#6366f1', strokeWidth: 1.5 },
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: '#6366f1' },
}}
connectionLineStyle={{ stroke: '#6366f1', strokeWidth: 1.5 }}
deleteKeyCode="Delete"
>
<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>
</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';
}
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 };
}
+28 -18
View File
@@ -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),
}),
);
+33
View File
@@ -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);
}
}
});
});