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);
+ }
+ }
+ });
+});