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 ( +