feat: replace form-based pattern editor with interactive graph canvas

Replace the flat agent list and static flow diagrams in PatternEditor with
an interactive React Flow canvas backed by the authoritative PatternGraph
model. Users can now see and manipulate orchestration topology visually.

Changes:
- Add @xyflow/react (React Flow) as a canvas library
- Create patternGraph view-model layer for graph-to-canvas projection
- Create GraphNodes with custom system/agent node components
- Create PatternGraphCanvas with drag, connect, and selection support
- Create PatternGraphInspector for selected node/agent details
- Rewrite PatternEditor with split layout: canvas + inspector sidebar
- Add dark theme CSS overrides for React Flow
- Add 11 tests covering view-model projection, connection rules, and
  graph mutation helpers
- Update ARCHITECTURE.md to reflect the new renderer graph boundary

The canvas projects PatternGraph into React Flow nodes/edges. Handoff mode
supports drawing new agent-to-agent edges interactively. Node positions
persist to the graph on drag completion. Agent editing moves from inline
forms to the inspector panel on node selection.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-24 22:54:02 +01:00
co-authored by Copilot
parent 2e2caf78f9
commit 7d078faada
10 changed files with 1106 additions and 342 deletions
@@ -0,0 +1,136 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ReactFlow,
Background,
BackgroundVariant,
useNodesState,
useEdgesState,
type Node,
type OnConnect,
type OnNodesChange,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import type { PatternDefinition, PatternGraph } from '@shared/domain/pattern';
import { resolvePatternGraph } from '@shared/domain/pattern';
import {
addHandoffEdge,
fromCanvasPositions,
isConnectionAllowed,
toCanvasEdges,
toCanvasNodes,
type GraphNodeData,
} from '@renderer/lib/patternGraph';
import { graphNodeTypes } from './GraphNodes';
interface PatternGraphCanvasProps {
pattern: PatternDefinition;
onGraphChange: (graph: PatternGraph) => void;
onNodeSelect: (nodeId: string | null) => void;
selectedNodeId: string | null;
}
export function PatternGraphCanvas({
pattern,
onGraphChange,
onNodeSelect,
selectedNodeId,
}: PatternGraphCanvasProps) {
const graph = useMemo(() => resolvePatternGraph(pattern), [pattern]);
const draggingRef = useRef(false);
const [nodes, setNodes, onNodesChange] = useNodesState(
toCanvasNodes(graph, pattern.agents),
);
const [edges, setEdges, onEdgesChange] = useEdgesState(
toCanvasEdges(graph, pattern.mode),
);
// Sync canvas when pattern changes externally
useEffect(() => {
setNodes(toCanvasNodes(graph, pattern.agents));
setEdges(toCanvasEdges(graph, pattern.mode));
}, [graph, pattern.agents, pattern.mode, setNodes, setEdges]);
const handleNodesChange: OnNodesChange<Node<GraphNodeData>> = useCallback(
(changes) => {
onNodesChange(changes);
const hasDragStart = changes.some(
(c) => c.type === 'position' && 'dragging' in c && c.dragging,
);
const hasDragStop = changes.some(
(c) => c.type === 'position' && !('dragging' in c && c.dragging),
);
if (hasDragStart) {
draggingRef.current = true;
}
if (hasDragStop && draggingRef.current) {
draggingRef.current = false;
setNodes((currentNodes) => {
const updatedGraph = fromCanvasPositions(pattern, currentNodes);
onGraphChange(updatedGraph);
return currentNodes;
});
}
},
[onNodesChange, pattern, onGraphChange, setNodes],
);
const handleConnect: OnConnect = useCallback(
(connection) => {
if (!isConnectionAllowed(connection, pattern.mode, graph)) {
return;
}
if (connection.source && connection.target) {
const updatedGraph = addHandoffEdge(graph, connection.source, connection.target);
onGraphChange(updatedGraph);
}
},
[graph, pattern.mode, onGraphChange],
);
const handleNodeClick = useCallback(
(_event: React.MouseEvent, node: Node<GraphNodeData>) => {
onNodeSelect(node.id);
},
[onNodeSelect],
);
const handlePaneClick = useCallback(() => {
onNodeSelect(null);
}, [onNodeSelect]);
return (
<div className="h-full w-full rounded-xl border border-zinc-800 bg-zinc-950/50">
<ReactFlow
nodes={nodes.map((n) => ({
...n,
selected: n.id === selectedNodeId,
}))}
edges={edges}
onNodesChange={handleNodesChange}
onEdgesChange={onEdgesChange}
onConnect={handleConnect}
onNodeClick={handleNodeClick}
onPaneClick={handlePaneClick}
nodeTypes={graphNodeTypes}
fitView
fitViewOptions={{ padding: 0.3 }}
minZoom={0.3}
maxZoom={2}
proOptions={{ hideAttribution: true }}
defaultEdgeOptions={{
style: { stroke: '#52525b', strokeWidth: 1.5 },
}}
connectionLineStyle={{ stroke: '#6366f1', strokeWidth: 1.5 }}
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#27272a" />
</ReactFlow>
</div>
);
}