From 7d078faada75dc5b313857510b3bc8cf111cbc21 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Tue, 24 Mar 2026 22:54:02 +0100 Subject: [PATCH] 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> --- ARCHITECTURE.md | 2 +- bun.lock | 41 ++ package.json | 1 + src/renderer/components/PatternEditor.tsx | 570 +++++++----------- .../components/pattern-graph/GraphNodes.tsx | 80 +++ .../pattern-graph/PatternGraphCanvas.tsx | 136 +++++ .../pattern-graph/PatternGraphInspector.tsx | 226 +++++++ src/renderer/lib/patternGraph.ts | 174 ++++++ src/renderer/styles.css | 47 ++ tests/renderer/patternGraph.test.ts | 171 ++++++ 10 files changed, 1106 insertions(+), 342 deletions(-) create mode 100644 src/renderer/components/pattern-graph/GraphNodes.tsx create mode 100644 src/renderer/components/pattern-graph/PatternGraphCanvas.tsx create mode 100644 src/renderer/components/pattern-graph/PatternGraphInspector.tsx create mode 100644 src/renderer/lib/patternGraph.ts create mode 100644 tests/renderer/patternGraph.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d1b6aaa..4099510 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -142,7 +142,7 @@ Patterns now persist an explicit graph-backed topology alongside the flat agent That graph is now the execution contract for the sidecar: sequential order comes from the saved path, handoff routes come from directed graph edges, and concurrent/group-chat participant ordering can be derived from graph node metadata instead of hard-coded runtime assumptions. -Until the dedicated canvas editor lands, the current form-based pattern editor keeps the graph synchronized by rebuilding the saved topology from the selected mode and agent list on change/save. This is an intentional temporary adapter so backend/runtime work can land before the renderer graph UX is replaced. +The pattern editor renders an interactive graph canvas powered by React Flow (`@xyflow/react`). The canvas projects the authoritative `PatternGraph` into React Flow nodes and edges via a view-model layer (`src/renderer/lib/patternGraph.ts`). Users can drag nodes to reposition them, and in handoff mode can draw new agent-to-agent edges directly on the canvas. A right-side inspector panel shows the details of the selected node — system node metadata for system nodes, or the full agent configuration form (model, reasoning, instructions) for agent nodes. The mode selector, pattern metadata, approval checkpoints, and tool auto-approval settings remain below the graph as scrollable settings sections. The `syncPatternGraph()` adapter is still called when agents are added/removed or the mode changes, rebuilding the graph from the current state; direct graph edits (drag positions, handoff edges) are persisted without the adapter. ### Sessions diff --git a/bun.lock b/bun.lock index f0c8f91..bf6222e 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "kopaya", "dependencies": { + "@xyflow/react": "^12.10.1", "keytar": "^7.9.0", "lucide-react": "^0.577.0", "react": "^19.2.4", @@ -232,6 +233,18 @@ "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], + + "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + + "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -264,6 +277,10 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.0", "", { "dependencies": { "@babel/core": "^7.28.4", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.43", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-4LuWrg7EKWgQaMJfnN+wcmbAW+VSsCmqGohftWjuct47bv8uE4n/nPpq4XjJPsxgq00GGG5J8dvBczp8uxScew=="], + "@xyflow/react": ["@xyflow/react@12.10.1", "", { "dependencies": { "@xyflow/system": "0.0.75", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q=="], + + "@xyflow/system": ["@xyflow/system@0.0.75", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ=="], + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], @@ -302,6 +319,8 @@ "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + "classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="], + "clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="], "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], @@ -314,6 +333,24 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], + + "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], @@ -726,6 +763,8 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], @@ -742,6 +781,8 @@ "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], + "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], diff --git a/package.json b/package.json index d783517..8e0b8b6 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "vite": "7.1.10" }, "dependencies": { + "@xyflow/react": "^12.10.1", "keytar": "^7.9.0", "lucide-react": "^0.577.0", "react": "^19.2.4", diff --git a/src/renderer/components/PatternEditor.tsx b/src/renderer/components/PatternEditor.tsx index f91ec27..a091f06 100644 --- a/src/renderer/components/PatternEditor.tsx +++ b/src/renderer/components/PatternEditor.tsx @@ -1,9 +1,8 @@ -import { type ReactNode } from 'react'; +import { useState } from 'react'; import { AlertCircle, ArrowLeftRight, CheckCircle, - ChevronDown, ChevronLeft, GitFork, ListOrdered, @@ -17,18 +16,15 @@ import { } from 'lucide-react'; import type { ApprovalCheckpointKind, ApprovalPolicy } from '@shared/domain/approval'; +import type { ModelDefinition } from '@shared/domain/models'; import { - findModel, - getSupportedReasoningEfforts, - resolveReasoningEffort, - type ModelDefinition, -} from '@shared/domain/models'; -import { + resolvePatternGraph, syncPatternGraph, validatePatternDefinition, type OrchestrationMode, type PatternDefinition, type PatternAgentDefinition, + type PatternGraph, } from '@shared/domain/pattern'; import { listApprovalToolDefinitions, @@ -38,7 +34,8 @@ import { type WorkspaceToolingSettings, } from '@shared/domain/tooling'; -import { ModelSelect, ReasoningEffortSelect } from './AgentConfigFields'; +import { PatternGraphCanvas } from './pattern-graph/PatternGraphCanvas'; +import { PatternGraphInspector } from './pattern-graph/PatternGraphInspector'; interface PatternEditorProps { availableModels: ReadonlyArray; @@ -91,86 +88,6 @@ const modeInfo: Record = { }, }; -function FlowPill({ children, variant = 'agent' }: { children: ReactNode; variant?: 'user' | 'agent' }) { - return ( - - {children} - - ); -} - -function FlowArrow({ label }: { label?: string }) { - return {label ?? '→'}; -} - -function ModeFlowDiagram({ mode }: { mode: OrchestrationMode }) { - switch (mode) { - case 'single': - return ( -
- You - - Agent - - You -
- ); - case 'sequential': - return ( -
- A₁ - - A₂ - - A₃ -
- ); - case 'concurrent': - return ( -
- You - -
- A₁ - A₂ - A₃ -
- - You -
- ); - case 'handoff': - return ( -
- Triage - - S₁ - S₂ -
- ); - case 'group-chat': - return ( -
- A₁ - - A₂ - - A₃ -
- ); - case 'magentic': - return Coming soon; - default: - return null; - } -} - function InputField({ label, value, @@ -219,12 +136,35 @@ export function PatternEditor({ onSave, onBack, }: PatternEditorProps) { + const [selectedNodeId, setSelectedNodeId] = useState(null); const issues = validatePatternDefinition(pattern); + const graph = resolvePatternGraph(pattern); function emitChange(nextPattern: PatternDefinition) { onChange(syncPatternGraph(nextPattern)); } + function emitGraphChange(nextGraph: PatternGraph) { + onChange({ ...pattern, graph: nextGraph }); + } + + function addAgent() { + emitChange({ + ...pattern, + agents: [ + ...pattern.agents, + { + id: `agent-${crypto.randomUUID()}`, + name: `Agent ${pattern.agents.length + 1}`, + description: '', + instructions: '', + model: 'gpt-5.4', + reasoningEffort: 'high', + }, + ], + }); + } + function updateAgent(agentId: string, patch: Partial) { emitChange({ ...pattern, @@ -232,12 +172,16 @@ export function PatternEditor({ }); } - function updateAgentModel(agent: PatternAgentDefinition, modelId: string) { - const model = findModel(modelId, availableModels); - updateAgent(agent.id, { - model: modelId, - reasoningEffort: resolveReasoningEffort(model, agent.reasoningEffort), + function removeAgent(agentId: string) { + if (pattern.agents.length <= 1) { + return; + } + + emitChange({ + ...pattern, + agents: pattern.agents.filter((a) => a.id !== agentId), }); + setSelectedNodeId(null); } function updateApprovalPolicy(updater: (current: ApprovalPolicy | undefined) => ApprovalPolicy | undefined) { @@ -289,7 +233,7 @@ export function PatternEditor({ return (
- {/* Header — consistent ← navigation */} + {/* Header */}
- {/* Body */} -
-
+ {/* Body — graph canvas + inspector split */} +
+ {/* Left column: graph canvas + settings below */} +
{/* Validation banner */} - {issues.length > 0 ? ( -
- {issues.map((issue, i) => ( -
- - {issue.message} -
- ))} -
- ) : ( -
- - Pattern is valid and ready to use -
- )} - - {/* Name + description */} -
-

- General -

- emitChange({ ...pattern, name: v })} - placeholder="Pattern name" - value={pattern.name} - /> - emitChange({ ...pattern, description: v })} - placeholder="What this pattern does..." - value={pattern.description} - /> -
- - {/* Mode selector cards */} -
-

- Orchestration Mode -

-
- {(Object.keys(modeInfo) as OrchestrationMode[]).map((mode) => { - const info = modeInfo[mode]; - const Icon = info.icon; - const selected = pattern.mode === mode; - const disabled = mode === 'magentic'; - - return ( - - ); - })} -
-
+ + {issue.message} +
+ ))} +
+ ) : ( +
+ + Pattern is valid +
+ )} +
- {/* Agents */} -
-
-

- Agents ({pattern.agents.length}) -

- -
+ {/* Graph canvas */} +
+

+ Topology +

+ +
-
- {pattern.agents.map((agent, index) => ( -
-
-
- - {index + 1} - - - {agent.name || 'Unnamed agent'} - -
- {pattern.agents.length > 1 && ( +
+ +
+ + {/* Scrollable settings below graph */} +
+
+ {/* General */} +
+

+ General +

+
+ emitChange({ ...pattern, name: v })} + placeholder="Pattern name" + value={pattern.name} + /> + emitChange({ ...pattern, description: v })} + placeholder="What this pattern does..." + value={pattern.description} + /> +
+
+ + {/* Mode selector */} +
+

+ Orchestration Mode +

+
+ {(Object.keys(modeInfo) as OrchestrationMode[]).map((mode) => { + const info = modeInfo[mode]; + const Icon = info.icon; + const selected = pattern.mode === mode; + const disabled = mode === 'magentic'; + + return ( + ); + })} +
+
+ + {/* Approval checkpoints */} +
+

+ Approval Checkpoints +

+ +

+ Pause the run for human review before risky actions or publishing responses. +

+ +
+ toggleCheckpoint('tool-call', enabled)} + scopedAgentIds={checkpointAgentIds('tool-call')} + onScopeChange={(agentIds) => setCheckpointAgentScope('tool-call', agentIds)} + /> + toggleCheckpoint('final-response', enabled)} + scopedAgentIds={checkpointAgentIds('final-response')} + onScopeChange={(agentIds) => setCheckpointAgentScope('final-response', agentIds)} + /> +
+
+ + {/* Tool auto-approval */} + {isCheckpointEnabled('tool-call') && ( +
+

+ Tool Auto-Approval Defaults +

+ +

+ Tools marked as auto-approved will skip manual review. + Sessions can override these defaults from the Activity panel. +

+ +
+ {approvalTools.length === 0 ? ( +

+ No tools available yet. Connect MCP servers or wait for runtime capabilities to load. +

+ ) : ( + )}
- -
- updateAgent(agent.id, { name: v })} - value={agent.name} - /> - updateAgentModel(agent, value)} - value={agent.model} - /> - updateAgent(agent.id, { reasoningEffort: value })} - supportedEfforts={getSupportedReasoningEfforts(findModel(agent.model, availableModels))} - value={resolveReasoningEffort(findModel(agent.model, availableModels), agent.reasoningEffort)} - /> -
-
- updateAgent(agent.id, { description: v })} - placeholder="What this agent does..." - value={agent.description} - /> -
-
- updateAgent(agent.id, { instructions: v })} - placeholder="System prompt for this agent..." - value={agent.instructions} - /> -
-
- ))} +
+ )}
- +
+
- {/* Approval checkpoints */} -
+ {/* Right column: node inspector */} +
+

- Approval Checkpoints + Inspector

- -

- Pause the run for human review before risky actions or publishing responses. -

- -
- toggleCheckpoint('tool-call', enabled)} - scopedAgentIds={checkpointAgentIds('tool-call')} - onScopeChange={(agentIds) => setCheckpointAgentScope('tool-call', agentIds)} - /> - toggleCheckpoint('final-response', enabled)} - scopedAgentIds={checkpointAgentIds('final-response')} - onScopeChange={(agentIds) => setCheckpointAgentScope('final-response', agentIds)} - /> -
-
- - {/* Tool auto-approval defaults — only relevant when tool-call approval is on */} - {isCheckpointEnabled('tool-call') && ( -
-

- Tool Auto-Approval Defaults -

- -

- Tools marked as auto-approved will skip manual review. - Sessions can override these defaults from the Activity panel. -

- -
- {approvalTools.length === 0 ? ( -

- No tools available yet. Connect MCP servers or wait for runtime capabilities to load. -

- ) : ( - - )} -
-
- )} + + diff --git a/src/renderer/components/pattern-graph/GraphNodes.tsx b/src/renderer/components/pattern-graph/GraphNodes.tsx new file mode 100644 index 0000000..edba39e --- /dev/null +++ b/src/renderer/components/pattern-graph/GraphNodes.tsx @@ -0,0 +1,80 @@ +import { memo } from 'react'; +import { Handle, Position, type NodeProps } from '@xyflow/react'; +import { CircleUser, Bot, Shuffle, Layers, Radio } from 'lucide-react'; + +import type { GraphNodeData } from '@renderer/lib/patternGraph'; +import type { PatternGraphNodeKind } from '@shared/domain/pattern'; + +const kindIcons: Record = { + 'user-input': CircleUser, + 'user-output': CircleUser, + agent: Bot, + distributor: Shuffle, + collector: Layers, + orchestrator: Radio, +}; + +const kindColors: Record = { + 'user-input': { bg: 'bg-indigo-500/10', border: 'border-indigo-500/30', text: 'text-indigo-400' }, + 'user-output': { bg: 'bg-indigo-500/10', border: 'border-indigo-500/30', text: 'text-indigo-400' }, + agent: { bg: 'bg-zinc-800/80', border: 'border-zinc-600/40', text: 'text-zinc-200' }, + distributor: { bg: 'bg-amber-500/10', border: 'border-amber-500/30', text: 'text-amber-400' }, + collector: { bg: 'bg-amber-500/10', border: 'border-amber-500/30', text: 'text-amber-400' }, + orchestrator: { bg: 'bg-emerald-500/10', border: 'border-emerald-500/30', text: 'text-emerald-400' }, +}; + +function GraphNodeContent({ data, selected }: { data: GraphNodeData; selected: boolean }) { + const Icon = kindIcons[data.kind] ?? Bot; + const colors = kindColors[data.kind] ?? kindColors.agent; + const isAgent = data.kind === 'agent'; + + return ( +
+ +
+
+ {data.label} +
+ {isAgent && typeof data.order === 'number' && ( +
#{data.order + 1}
+ )} +
+ {data.readOnly && ( + + SYS + + )} +
+ ); +} + +export const SystemNode = memo(function SystemNode({ data, selected }: NodeProps) { + const nodeData = data as unknown as GraphNodeData; + return ( + <> + + + + + ); +}); + +export const AgentNode = memo(function AgentNode({ data, selected }: NodeProps) { + const nodeData = data as unknown as GraphNodeData; + return ( + <> + + + + + ); +}); + +export const graphNodeTypes = { + systemNode: SystemNode, + agentNode: AgentNode, +}; diff --git a/src/renderer/components/pattern-graph/PatternGraphCanvas.tsx b/src/renderer/components/pattern-graph/PatternGraphCanvas.tsx new file mode 100644 index 0000000..efc103c --- /dev/null +++ b/src/renderer/components/pattern-graph/PatternGraphCanvas.tsx @@ -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> = 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) => { + onNodeSelect(node.id); + }, + [onNodeSelect], + ); + + const handlePaneClick = useCallback(() => { + onNodeSelect(null); + }, [onNodeSelect]); + + return ( +
+ ({ + ...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 }} + > + + +
+ ); +} diff --git a/src/renderer/components/pattern-graph/PatternGraphInspector.tsx b/src/renderer/components/pattern-graph/PatternGraphInspector.tsx new file mode 100644 index 0000000..d9194d1 --- /dev/null +++ b/src/renderer/components/pattern-graph/PatternGraphInspector.tsx @@ -0,0 +1,226 @@ +import { Bot, CircleUser, Layers, Plus, Radio, Shuffle, Trash2 } from 'lucide-react'; + +import { + findModel, + getSupportedReasoningEfforts, + resolveReasoningEffort, + type ModelDefinition, +} from '@shared/domain/models'; +import type { PatternAgentDefinition, PatternGraph, PatternGraphNodeKind } from '@shared/domain/pattern'; +import { findAgentForNode } from '@renderer/lib/patternGraph'; +import { ModelSelect, ReasoningEffortSelect } from '../AgentConfigFields'; + +interface PatternGraphInspectorProps { + availableModels: ReadonlyArray; + agents: PatternAgentDefinition[]; + graph: PatternGraph; + selectedNodeId: string | null; + onAgentChange: (agentId: string, patch: Partial) => void; + onAgentRemove: (agentId: string) => void; +} + +function InputField({ + label, + value, + onChange, + multiline, + placeholder, +}: { + label: string; + value: string; + onChange: (value: string) => void; + multiline?: boolean; + placeholder?: string; +}) { + const base = + 'w-full rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2 text-[13px] text-zinc-100 placeholder-zinc-600 outline-none transition focus:border-indigo-500/50'; + return ( +