mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 21:48:36 +02:00
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:
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<ModelDefinition>;
|
||||
@@ -91,86 +88,6 @@ const modeInfo: Record<OrchestrationMode, ModeInfo> = {
|
||||
},
|
||||
};
|
||||
|
||||
function FlowPill({ children, variant = 'agent' }: { children: ReactNode; variant?: 'user' | 'agent' }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex rounded px-1.5 py-0.5 text-[9px] font-semibold leading-none ${
|
||||
variant === 'user'
|
||||
? 'bg-indigo-500/20 text-indigo-400'
|
||||
: 'bg-zinc-700/60 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowArrow({ label }: { label?: string }) {
|
||||
return <span className="text-[10px] leading-none text-zinc-600">{label ?? '→'}</span>;
|
||||
}
|
||||
|
||||
function ModeFlowDiagram({ mode }: { mode: OrchestrationMode }) {
|
||||
switch (mode) {
|
||||
case 'single':
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<FlowPill variant="user">You</FlowPill>
|
||||
<FlowArrow />
|
||||
<FlowPill>Agent</FlowPill>
|
||||
<FlowArrow />
|
||||
<FlowPill variant="user">You</FlowPill>
|
||||
</div>
|
||||
);
|
||||
case 'sequential':
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<FlowPill>A₁</FlowPill>
|
||||
<FlowArrow />
|
||||
<FlowPill>A₂</FlowPill>
|
||||
<FlowArrow />
|
||||
<FlowPill>A₃</FlowPill>
|
||||
</div>
|
||||
);
|
||||
case 'concurrent':
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<FlowPill variant="user">You</FlowPill>
|
||||
<FlowArrow />
|
||||
<div className="flex gap-0.5">
|
||||
<FlowPill>A₁</FlowPill>
|
||||
<FlowPill>A₂</FlowPill>
|
||||
<FlowPill>A₃</FlowPill>
|
||||
</div>
|
||||
<FlowArrow />
|
||||
<FlowPill variant="user">You</FlowPill>
|
||||
</div>
|
||||
);
|
||||
case 'handoff':
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<FlowPill>Triage</FlowPill>
|
||||
<FlowArrow label="⇄" />
|
||||
<FlowPill>S₁</FlowPill>
|
||||
<FlowPill>S₂</FlowPill>
|
||||
</div>
|
||||
);
|
||||
case 'group-chat':
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<FlowPill>A₁</FlowPill>
|
||||
<FlowArrow label="⇄" />
|
||||
<FlowPill>A₂</FlowPill>
|
||||
<FlowArrow label="⇄" />
|
||||
<FlowPill>A₃</FlowPill>
|
||||
</div>
|
||||
);
|
||||
case 'magentic':
|
||||
return <span className="text-[10px] italic text-zinc-700">Coming soon</span>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function InputField({
|
||||
label,
|
||||
value,
|
||||
@@ -219,12 +136,35 @@ export function PatternEditor({
|
||||
onSave,
|
||||
onBack,
|
||||
}: PatternEditorProps) {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(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<PatternAgentDefinition>) {
|
||||
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 (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header — consistent ← navigation */}
|
||||
{/* Header */}
|
||||
<div className="drag-region flex items-center justify-between border-b border-[var(--color-border)] px-5 pb-3 pt-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
@@ -329,267 +273,211 @@ export function PatternEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||
<div className="mx-auto max-w-2xl space-y-8">
|
||||
{/* Body — graph canvas + inspector split */}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Left column: graph canvas + settings below */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{/* Validation banner */}
|
||||
{issues.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{issues.map((issue, i) => (
|
||||
<div
|
||||
className={`flex items-start gap-2 rounded-lg px-3 py-2 text-[13px] ${
|
||||
issue.level === 'error'
|
||||
? 'bg-red-500/10 text-red-300'
|
||||
: 'bg-amber-500/10 text-amber-300'
|
||||
}`}
|
||||
key={`${issue.field ?? 'v'}-${i}`}
|
||||
>
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
{issue.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-[13px] text-emerald-300">
|
||||
<CheckCircle className="size-3.5" />
|
||||
Pattern is valid and ready to use
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name + description */}
|
||||
<section className="space-y-4">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
General
|
||||
</h4>
|
||||
<InputField
|
||||
label="Name"
|
||||
onChange={(v) => emitChange({ ...pattern, name: v })}
|
||||
placeholder="Pattern name"
|
||||
value={pattern.name}
|
||||
/>
|
||||
<InputField
|
||||
label="Description"
|
||||
multiline
|
||||
onChange={(v) => emitChange({ ...pattern, description: v })}
|
||||
placeholder="What this pattern does..."
|
||||
value={pattern.description}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Mode selector cards */}
|
||||
<section className="space-y-4">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Orchestration Mode
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(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 (
|
||||
<button
|
||||
className={`flex flex-col rounded-xl border p-3 text-left transition ${
|
||||
selected
|
||||
? 'border-indigo-500/40 bg-indigo-500/5 ring-1 ring-indigo-500/20'
|
||||
: disabled
|
||||
? 'cursor-not-allowed border-zinc-800/50 opacity-40'
|
||||
: 'border-zinc-800 hover:border-zinc-700 hover:bg-zinc-900/60'
|
||||
<div className="px-5 pt-4">
|
||||
{issues.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{issues.map((issue, i) => (
|
||||
<div
|
||||
className={`flex items-start gap-2 rounded-lg px-3 py-2 text-[12px] ${
|
||||
issue.level === 'error'
|
||||
? 'bg-red-500/10 text-red-300'
|
||||
: 'bg-amber-500/10 text-amber-300'
|
||||
}`}
|
||||
disabled={disabled}
|
||||
key={mode}
|
||||
onClick={() => emitChange({ ...pattern, mode })}
|
||||
type="button"
|
||||
key={`${issue.field ?? 'v'}-${i}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon
|
||||
className={`size-4 ${selected ? 'text-indigo-400' : 'text-zinc-500'}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-[12px] font-semibold ${
|
||||
selected ? 'text-indigo-200' : 'text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{info.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] leading-snug text-zinc-500">
|
||||
{info.description}
|
||||
</p>
|
||||
<div className="mt-2.5">
|
||||
<ModeFlowDiagram mode={mode} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
{issue.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-[12px] text-emerald-300">
|
||||
<CheckCircle className="size-3.5" />
|
||||
Pattern is valid
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Agents */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Agents ({pattern.agents.length})
|
||||
</h4>
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-[12px] font-medium text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
|
||||
onClick={() =>
|
||||
emitChange({
|
||||
...pattern,
|
||||
agents: [
|
||||
...pattern.agents,
|
||||
{
|
||||
id: `agent-${crypto.randomUUID()}`,
|
||||
name: `Agent ${pattern.agents.length + 1}`,
|
||||
description: '',
|
||||
instructions: '',
|
||||
model: 'gpt-5.4',
|
||||
reasoningEffort: 'high',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
Add agent
|
||||
</button>
|
||||
</div>
|
||||
{/* Graph canvas */}
|
||||
<div className="flex items-center justify-between px-5 pt-4">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Topology
|
||||
</h4>
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-[12px] font-medium text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
|
||||
onClick={addAgent}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
Add agent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{pattern.agents.map((agent, index) => (
|
||||
<div
|
||||
className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4"
|
||||
key={agent.id}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex size-5 items-center justify-center rounded bg-zinc-700/60 text-[10px] font-bold text-zinc-400">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="text-[12px] font-medium text-zinc-300">
|
||||
{agent.name || 'Unnamed agent'}
|
||||
</span>
|
||||
</div>
|
||||
{pattern.agents.length > 1 && (
|
||||
<div className="min-h-[300px] flex-1 px-5 py-3">
|
||||
<PatternGraphCanvas
|
||||
pattern={pattern}
|
||||
onGraphChange={emitGraphChange}
|
||||
onNodeSelect={setSelectedNodeId}
|
||||
selectedNodeId={selectedNodeId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scrollable settings below graph */}
|
||||
<div className="max-h-[45%] overflow-y-auto border-t border-zinc-800/50 px-5 py-5">
|
||||
<div className="space-y-8">
|
||||
{/* General */}
|
||||
<section className="space-y-4">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
General
|
||||
</h4>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<InputField
|
||||
label="Name"
|
||||
onChange={(v) => emitChange({ ...pattern, name: v })}
|
||||
placeholder="Pattern name"
|
||||
value={pattern.name}
|
||||
/>
|
||||
<InputField
|
||||
label="Description"
|
||||
onChange={(v) => emitChange({ ...pattern, description: v })}
|
||||
placeholder="What this pattern does..."
|
||||
value={pattern.description}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mode selector */}
|
||||
<section className="space-y-4">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Orchestration Mode
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(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 (
|
||||
<button
|
||||
className="flex items-center gap-1 text-[12px] text-zinc-600 transition hover:text-red-400"
|
||||
onClick={() =>
|
||||
emitChange({
|
||||
...pattern,
|
||||
agents: pattern.agents.filter((a) => a.id !== agent.id),
|
||||
})
|
||||
}
|
||||
className={`flex flex-col rounded-xl border p-2.5 text-left transition ${
|
||||
selected
|
||||
? 'border-indigo-500/40 bg-indigo-500/5 ring-1 ring-indigo-500/20'
|
||||
: disabled
|
||||
? 'cursor-not-allowed border-zinc-800/50 opacity-40'
|
||||
: 'border-zinc-800 hover:border-zinc-700 hover:bg-zinc-900/60'
|
||||
}`}
|
||||
disabled={disabled}
|
||||
key={mode}
|
||||
onClick={() => emitChange({ ...pattern, mode })}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Remove
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icon
|
||||
className={`size-3.5 ${selected ? 'text-indigo-400' : 'text-zinc-500'}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-[11px] font-semibold ${
|
||||
selected ? 'text-indigo-200' : 'text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{info.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] leading-snug text-zinc-500">
|
||||
{info.description}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Approval checkpoints */}
|
||||
<section className="space-y-4">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Approval Checkpoints
|
||||
</h4>
|
||||
|
||||
<p className="text-[11px] leading-relaxed text-zinc-600">
|
||||
Pause the run for human review before risky actions or publishing responses.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<ApprovalCheckpointRow
|
||||
agents={pattern.agents}
|
||||
enabled={isCheckpointEnabled('tool-call')}
|
||||
kind="tool-call"
|
||||
label="Tool call approval"
|
||||
description="Require approval before the agent executes tool calls"
|
||||
onToggle={(enabled) => toggleCheckpoint('tool-call', enabled)}
|
||||
scopedAgentIds={checkpointAgentIds('tool-call')}
|
||||
onScopeChange={(agentIds) => setCheckpointAgentScope('tool-call', agentIds)}
|
||||
/>
|
||||
<ApprovalCheckpointRow
|
||||
agents={pattern.agents}
|
||||
enabled={isCheckpointEnabled('final-response')}
|
||||
kind="final-response"
|
||||
label="Final response review"
|
||||
description="Review and approve assistant messages before publication"
|
||||
onToggle={(enabled) => toggleCheckpoint('final-response', enabled)}
|
||||
scopedAgentIds={checkpointAgentIds('final-response')}
|
||||
onScopeChange={(agentIds) => setCheckpointAgentScope('final-response', agentIds)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tool auto-approval */}
|
||||
{isCheckpointEnabled('tool-call') && (
|
||||
<section className="space-y-4">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Tool Auto-Approval Defaults
|
||||
</h4>
|
||||
|
||||
<p className="text-[11px] leading-relaxed text-zinc-600">
|
||||
Tools marked as auto-approved will skip manual review.
|
||||
Sessions can override these defaults from the Activity panel.
|
||||
</p>
|
||||
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 px-4 py-3">
|
||||
{approvalTools.length === 0 ? (
|
||||
<p className="py-2 text-center text-[11px] text-zinc-600">
|
||||
No tools available yet. Connect MCP servers or wait for runtime capabilities to load.
|
||||
</p>
|
||||
) : (
|
||||
<ToolApprovalGroupedList
|
||||
autoApprovedSet={autoApprovedSet}
|
||||
onToggle={toggleToolAutoApproval}
|
||||
tools={approvalTools}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<InputField
|
||||
label="Name"
|
||||
onChange={(v) => updateAgent(agent.id, { name: v })}
|
||||
value={agent.name}
|
||||
/>
|
||||
<ModelSelect
|
||||
models={availableModels}
|
||||
onChange={(value) => updateAgentModel(agent, value)}
|
||||
value={agent.model}
|
||||
/>
|
||||
<ReasoningEffortSelect
|
||||
label="Reasoning"
|
||||
onChange={(value) => updateAgent(agent.id, { reasoningEffort: value })}
|
||||
supportedEfforts={getSupportedReasoningEfforts(findModel(agent.model, availableModels))}
|
||||
value={resolveReasoningEffort(findModel(agent.model, availableModels), agent.reasoningEffort)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<InputField
|
||||
label="Description"
|
||||
onChange={(v) => updateAgent(agent.id, { description: v })}
|
||||
placeholder="What this agent does..."
|
||||
value={agent.description}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<InputField
|
||||
label="Instructions"
|
||||
multiline
|
||||
onChange={(v) => updateAgent(agent.id, { instructions: v })}
|
||||
placeholder="System prompt for this agent..."
|
||||
value={agent.instructions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Approval checkpoints */}
|
||||
<section className="space-y-4">
|
||||
{/* Right column: node inspector */}
|
||||
<div className="w-[320px] shrink-0 overflow-y-auto border-l border-zinc-800/50 bg-zinc-900/30">
|
||||
<div className="border-b border-zinc-800/50 px-4 py-3">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Approval Checkpoints
|
||||
Inspector
|
||||
</h4>
|
||||
|
||||
<p className="text-[11px] leading-relaxed text-zinc-600">
|
||||
Pause the run for human review before risky actions or publishing responses.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<ApprovalCheckpointRow
|
||||
agents={pattern.agents}
|
||||
enabled={isCheckpointEnabled('tool-call')}
|
||||
kind="tool-call"
|
||||
label="Tool call approval"
|
||||
description="Require approval before the agent executes tool calls"
|
||||
onToggle={(enabled) => toggleCheckpoint('tool-call', enabled)}
|
||||
scopedAgentIds={checkpointAgentIds('tool-call')}
|
||||
onScopeChange={(agentIds) => setCheckpointAgentScope('tool-call', agentIds)}
|
||||
/>
|
||||
<ApprovalCheckpointRow
|
||||
agents={pattern.agents}
|
||||
enabled={isCheckpointEnabled('final-response')}
|
||||
kind="final-response"
|
||||
label="Final response review"
|
||||
description="Review and approve assistant messages before publication"
|
||||
onToggle={(enabled) => toggleCheckpoint('final-response', enabled)}
|
||||
scopedAgentIds={checkpointAgentIds('final-response')}
|
||||
onScopeChange={(agentIds) => setCheckpointAgentScope('final-response', agentIds)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tool auto-approval defaults — only relevant when tool-call approval is on */}
|
||||
{isCheckpointEnabled('tool-call') && (
|
||||
<section className="space-y-4">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Tool Auto-Approval Defaults
|
||||
</h4>
|
||||
|
||||
<p className="text-[11px] leading-relaxed text-zinc-600">
|
||||
Tools marked as auto-approved will skip manual review.
|
||||
Sessions can override these defaults from the Activity panel.
|
||||
</p>
|
||||
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 px-4 py-3">
|
||||
{approvalTools.length === 0 ? (
|
||||
<p className="py-2 text-center text-[11px] text-zinc-600">
|
||||
No tools available yet. Connect MCP servers or wait for runtime capabilities to load.
|
||||
</p>
|
||||
) : (
|
||||
<ToolApprovalGroupedList
|
||||
autoApprovedSet={autoApprovedSet}
|
||||
onToggle={toggleToolAutoApproval}
|
||||
tools={approvalTools}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
<PatternGraphInspector
|
||||
availableModels={availableModels}
|
||||
agents={pattern.agents}
|
||||
graph={graph}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onAgentChange={updateAgent}
|
||||
onAgentRemove={removeAgent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<PatternGraphNodeKind, typeof CircleUser> = {
|
||||
'user-input': CircleUser,
|
||||
'user-output': CircleUser,
|
||||
agent: Bot,
|
||||
distributor: Shuffle,
|
||||
collector: Layers,
|
||||
orchestrator: Radio,
|
||||
};
|
||||
|
||||
const kindColors: Record<PatternGraphNodeKind, { bg: string; border: string; text: string }> = {
|
||||
'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 (
|
||||
<div
|
||||
className={`flex min-w-[120px] items-center gap-2 rounded-xl border px-3 py-2 shadow-md transition ${
|
||||
colors.bg
|
||||
} ${selected ? 'ring-2 ring-indigo-500/50' : ''} ${colors.border}`}
|
||||
>
|
||||
<Icon className={`size-4 shrink-0 ${colors.text}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`truncate text-[12px] font-semibold ${colors.text}`}>
|
||||
{data.label}
|
||||
</div>
|
||||
{isAgent && typeof data.order === 'number' && (
|
||||
<div className="text-[10px] text-zinc-500">#{data.order + 1}</div>
|
||||
)}
|
||||
</div>
|
||||
{data.readOnly && (
|
||||
<span className="ml-1 rounded bg-zinc-700/50 px-1 py-0.5 text-[8px] font-medium text-zinc-500">
|
||||
SYS
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const SystemNode = memo(function SystemNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as unknown as GraphNodeData;
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className="!size-2 !border-zinc-600 !bg-zinc-400" />
|
||||
<GraphNodeContent data={nodeData} selected={selected ?? false} />
|
||||
<Handle type="source" position={Position.Right} className="!size-2 !border-zinc-600 !bg-zinc-400" />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const AgentNode = memo(function AgentNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as unknown as GraphNodeData;
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className="!size-2 !border-indigo-400 !bg-indigo-500" />
|
||||
<GraphNodeContent data={nodeData} selected={selected ?? false} />
|
||||
<Handle type="source" position={Position.Right} className="!size-2 !border-indigo-400 !bg-indigo-500" />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const graphNodeTypes = {
|
||||
systemNode: SystemNode,
|
||||
agentNode: AgentNode,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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<ModelDefinition>;
|
||||
agents: PatternAgentDefinition[];
|
||||
graph: PatternGraph;
|
||||
selectedNodeId: string | null;
|
||||
onAgentChange: (agentId: string, patch: Partial<PatternAgentDefinition>) => 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 (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-zinc-400">{label}</span>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
className={`${base} min-h-20 resize-y`}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className={base}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const kindIcons: Record<PatternGraphNodeKind, typeof Bot> = {
|
||||
'user-input': CircleUser,
|
||||
'user-output': CircleUser,
|
||||
agent: Bot,
|
||||
distributor: Shuffle,
|
||||
collector: Layers,
|
||||
orchestrator: Radio,
|
||||
};
|
||||
|
||||
const kindLabels: Partial<Record<PatternGraphNodeKind, string>> = {
|
||||
'user-input': 'User Input',
|
||||
'user-output': 'User Output',
|
||||
distributor: 'Distributor',
|
||||
collector: 'Collector',
|
||||
orchestrator: 'Orchestrator',
|
||||
};
|
||||
|
||||
const kindDescriptions: Partial<Record<PatternGraphNodeKind, string>> = {
|
||||
'user-input': 'Entry point — receives user messages.',
|
||||
'user-output': 'Exit point — returns final response to the user.',
|
||||
distributor: 'Fans user input to all agents in parallel.',
|
||||
collector: 'Aggregates parallel agent responses.',
|
||||
orchestrator: 'Manages group chat round-robin turns.',
|
||||
};
|
||||
|
||||
function SystemNodeInspector({ kind }: { kind: PatternGraphNodeKind }) {
|
||||
const Icon = kindIcons[kind];
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-indigo-500/10">
|
||||
<Icon className="size-4 text-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[13px] font-semibold text-zinc-200">{kindLabels[kind]}</div>
|
||||
<span className="rounded bg-zinc-700/50 px-1.5 py-0.5 text-[9px] font-medium text-zinc-500">
|
||||
System node
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[12px] leading-relaxed text-zinc-500">{kindDescriptions[kind]}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentNodeInspector({
|
||||
agent,
|
||||
availableModels,
|
||||
onAgentChange,
|
||||
onAgentRemove,
|
||||
}: {
|
||||
agent: PatternAgentDefinition;
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
onAgentChange: (agentId: string, patch: Partial<PatternAgentDefinition>) => void;
|
||||
onAgentRemove: (agentId: string) => void;
|
||||
}) {
|
||||
const model = findModel(agent.model, availableModels);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-zinc-800">
|
||||
<Bot className="size-4 text-zinc-300" />
|
||||
</div>
|
||||
<div className="text-[13px] font-semibold text-zinc-200">{agent.name || 'Unnamed'}</div>
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center gap-1 text-[12px] text-zinc-600 transition hover:text-red-400"
|
||||
onClick={() => onAgentRemove(agent.id)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<InputField
|
||||
label="Name"
|
||||
onChange={(v) => onAgentChange(agent.id, { name: v })}
|
||||
value={agent.name}
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<ModelSelect
|
||||
models={availableModels}
|
||||
onChange={(value) => {
|
||||
const m = findModel(value, availableModels);
|
||||
onAgentChange(agent.id, {
|
||||
model: value,
|
||||
reasoningEffort: resolveReasoningEffort(m, agent.reasoningEffort),
|
||||
});
|
||||
}}
|
||||
value={agent.model}
|
||||
/>
|
||||
<ReasoningEffortSelect
|
||||
label="Reasoning"
|
||||
onChange={(value) => onAgentChange(agent.id, { reasoningEffort: value })}
|
||||
supportedEfforts={getSupportedReasoningEfforts(model)}
|
||||
value={resolveReasoningEffort(model, agent.reasoningEffort)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InputField
|
||||
label="Description"
|
||||
onChange={(v) => onAgentChange(agent.id, { description: v })}
|
||||
placeholder="What this agent does..."
|
||||
value={agent.description}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
label="Instructions"
|
||||
multiline
|
||||
onChange={(v) => onAgentChange(agent.id, { instructions: v })}
|
||||
placeholder="System prompt for this agent..."
|
||||
value={agent.instructions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PatternGraphInspector({
|
||||
availableModels,
|
||||
agents,
|
||||
graph,
|
||||
selectedNodeId,
|
||||
onAgentChange,
|
||||
onAgentRemove,
|
||||
}: PatternGraphInspectorProps) {
|
||||
if (!selectedNodeId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
<p className="text-center text-[12px] text-zinc-600">
|
||||
Select a node on the graph to inspect it
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const node = graph.nodes.find((n) => n.id === selectedNodeId);
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.kind !== 'agent') {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<SystemNodeInspector kind={node.kind} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const agent = findAgentForNode(selectedNodeId, graph, agents);
|
||||
if (!agent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<AgentNodeInspector
|
||||
agent={agent}
|
||||
availableModels={availableModels}
|
||||
onAgentChange={onAgentChange}
|
||||
onAgentRemove={onAgentRemove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { Node, Edge, Connection } from '@xyflow/react';
|
||||
|
||||
import type {
|
||||
OrchestrationMode,
|
||||
PatternAgentDefinition,
|
||||
PatternDefinition,
|
||||
PatternGraph,
|
||||
PatternGraphEdge,
|
||||
PatternGraphNode,
|
||||
PatternGraphNodeKind,
|
||||
} from '@shared/domain/pattern';
|
||||
import { resolvePatternGraph } from '@shared/domain/pattern';
|
||||
|
||||
/* ── Canvas node data ──────────────────────────────────────── */
|
||||
|
||||
export interface GraphNodeData extends Record<string, unknown> {
|
||||
label: string;
|
||||
kind: PatternGraphNodeKind;
|
||||
agentId?: string;
|
||||
order?: number;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
/* ── View-model projection ─────────────────────────────────── */
|
||||
|
||||
const SYSTEM_NODE_LABELS: Partial<Record<PatternGraphNodeKind, string>> = {
|
||||
'user-input': 'User Input',
|
||||
'user-output': 'User Output',
|
||||
distributor: 'Distributor',
|
||||
collector: 'Collector',
|
||||
orchestrator: 'Orchestrator',
|
||||
};
|
||||
|
||||
function isSystemNode(kind: PatternGraphNodeKind): boolean {
|
||||
return kind !== 'agent';
|
||||
}
|
||||
|
||||
function resolveNodeLabel(node: PatternGraphNode, agents: PatternAgentDefinition[]): string {
|
||||
if (node.kind === 'agent' && node.agentId) {
|
||||
const agent = agents.find((a) => a.id === node.agentId);
|
||||
return agent?.name || node.agentId;
|
||||
}
|
||||
|
||||
return SYSTEM_NODE_LABELS[node.kind] ?? node.kind;
|
||||
}
|
||||
|
||||
function resolveNodeType(kind: PatternGraphNodeKind): string {
|
||||
if (kind === 'agent') {
|
||||
return 'agentNode';
|
||||
}
|
||||
|
||||
return 'systemNode';
|
||||
}
|
||||
|
||||
export function toCanvasNodes(graph: PatternGraph, agents: PatternAgentDefinition[]): Node<GraphNodeData>[] {
|
||||
return graph.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: resolveNodeType(node.kind),
|
||||
position: { x: node.position.x, y: node.position.y },
|
||||
data: {
|
||||
label: resolveNodeLabel(node, agents),
|
||||
kind: node.kind,
|
||||
agentId: node.agentId,
|
||||
order: node.order,
|
||||
readOnly: isSystemNode(node.kind),
|
||||
},
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
deletable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
export function toCanvasEdges(graph: PatternGraph, mode: OrchestrationMode): Edge[] {
|
||||
return graph.edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: 'default',
|
||||
animated: mode === 'handoff',
|
||||
deletable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
export function fromCanvasPositions(
|
||||
pattern: PatternDefinition,
|
||||
canvasNodes: Node<GraphNodeData>[],
|
||||
): PatternGraph {
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const positionMap = new Map(canvasNodes.map((n) => [n.id, n.position]));
|
||||
|
||||
return {
|
||||
nodes: graph.nodes.map((node) => {
|
||||
const pos = positionMap.get(node.id);
|
||||
return pos ? { ...node, position: { x: Math.round(pos.x), y: Math.round(pos.y) } } : node;
|
||||
}),
|
||||
edges: graph.edges,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Connection rules ──────────────────────────────────────── */
|
||||
|
||||
export function isConnectionAllowed(
|
||||
connection: Connection,
|
||||
mode: OrchestrationMode,
|
||||
graph: PatternGraph,
|
||||
): boolean {
|
||||
if (!connection.source || !connection.target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (connection.source === connection.target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sourceNode = graph.nodes.find((n) => n.id === connection.source);
|
||||
const targetNode = graph.nodes.find((n) => n.id === connection.target);
|
||||
if (!sourceNode || !targetNode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case 'single':
|
||||
case 'sequential':
|
||||
case 'magentic':
|
||||
return false;
|
||||
case 'concurrent':
|
||||
return false;
|
||||
case 'handoff':
|
||||
return sourceNode.kind === 'agent' && targetNode.kind === 'agent';
|
||||
case 'group-chat':
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Graph mutation helpers ─────────────────────────────────── */
|
||||
|
||||
function edgeId(source: string, target: string): string {
|
||||
return `edge-${source}-to-${target}`;
|
||||
}
|
||||
|
||||
export function addHandoffEdge(graph: PatternGraph, source: string, target: string): PatternGraph {
|
||||
const newEdge: PatternGraphEdge = {
|
||||
id: edgeId(source, target),
|
||||
source,
|
||||
target,
|
||||
};
|
||||
|
||||
if (graph.edges.some((e) => e.source === source && e.target === target)) {
|
||||
return graph;
|
||||
}
|
||||
|
||||
return { ...graph, edges: [...graph.edges, newEdge] };
|
||||
}
|
||||
|
||||
export function removeEdge(graph: PatternGraph, removeEdgeId: string): PatternGraph {
|
||||
return { ...graph, edges: graph.edges.filter((e) => e.id !== removeEdgeId) };
|
||||
}
|
||||
|
||||
/* ── Agent node for selected inspector ─────────────────────── */
|
||||
|
||||
export function findAgentForNode(
|
||||
nodeId: string,
|
||||
graph: PatternGraph,
|
||||
agents: PatternAgentDefinition[],
|
||||
): PatternAgentDefinition | undefined {
|
||||
const node = graph.nodes.find((n) => n.id === nodeId);
|
||||
if (!node?.agentId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return agents.find((a) => a.id === node.agentId);
|
||||
}
|
||||
@@ -140,6 +140,53 @@ textarea {
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
/* React Flow dark theme overrides */
|
||||
.react-flow__node {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.react-flow__edge-path {
|
||||
stroke: #52525b;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.react-flow__edge.animated .react-flow__edge-path {
|
||||
stroke-dasharray: 5;
|
||||
animation: reactflow-dash 0.5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes reactflow-dash {
|
||||
to {
|
||||
stroke-dashoffset: -10;
|
||||
}
|
||||
}
|
||||
|
||||
.react-flow__controls {
|
||||
border: 1px solid #27272a;
|
||||
background: #18181b;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.react-flow__controls button {
|
||||
background: #18181b;
|
||||
border-color: #27272a;
|
||||
color: #a1a1aa;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.react-flow__controls button:hover {
|
||||
background: #27272a;
|
||||
color: #e4e4e7;
|
||||
}
|
||||
|
||||
.react-flow__minimap {
|
||||
background: #09090b;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Sidebar running-session pulse animation */
|
||||
@keyframes sidebar-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { createBuiltinPatterns, resolvePatternGraph, type PatternDefinition } from '@shared/domain/pattern';
|
||||
import {
|
||||
addHandoffEdge,
|
||||
findAgentForNode,
|
||||
isConnectionAllowed,
|
||||
removeEdge,
|
||||
toCanvasEdges,
|
||||
toCanvasNodes,
|
||||
} from '@renderer/lib/patternGraph';
|
||||
|
||||
const BUILTIN_TIMESTAMP = '2026-03-22T00:00:00.000Z';
|
||||
const patterns = createBuiltinPatterns(BUILTIN_TIMESTAMP);
|
||||
|
||||
function findPattern(mode: string): PatternDefinition {
|
||||
return patterns.find((p) => p.mode === mode)!;
|
||||
}
|
||||
|
||||
describe('pattern graph view model', () => {
|
||||
test('projects sequential graph into canvas nodes with correct kinds and labels', () => {
|
||||
const pattern = findPattern('sequential');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const nodes = toCanvasNodes(graph, pattern.agents);
|
||||
|
||||
expect(nodes.length).toBe(pattern.agents.length + 2);
|
||||
|
||||
const userInput = nodes.find((n) => n.data.kind === 'user-input');
|
||||
const userOutput = nodes.find((n) => n.data.kind === 'user-output');
|
||||
expect(userInput).toBeDefined();
|
||||
expect(userOutput).toBeDefined();
|
||||
expect(userInput!.data.readOnly).toBe(true);
|
||||
expect(userOutput!.data.readOnly).toBe(true);
|
||||
expect(userInput!.type).toBe('systemNode');
|
||||
|
||||
const agentNodes = nodes.filter((n) => n.data.kind === 'agent');
|
||||
expect(agentNodes.length).toBe(pattern.agents.length);
|
||||
expect(agentNodes[0]!.data.label).toBe(pattern.agents[0]!.name);
|
||||
expect(agentNodes[0]!.data.readOnly).toBe(false);
|
||||
expect(agentNodes[0]!.type).toBe('agentNode');
|
||||
});
|
||||
|
||||
test('projects concurrent graph with distributor and collector system nodes', () => {
|
||||
const pattern = findPattern('concurrent');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const nodes = toCanvasNodes(graph, pattern.agents);
|
||||
const edges = toCanvasEdges(graph, pattern.mode);
|
||||
|
||||
const distributor = nodes.find((n) => n.data.kind === 'distributor');
|
||||
const collector = nodes.find((n) => n.data.kind === 'collector');
|
||||
expect(distributor).toBeDefined();
|
||||
expect(collector).toBeDefined();
|
||||
expect(distributor!.data.readOnly).toBe(true);
|
||||
expect(collector!.data.readOnly).toBe(true);
|
||||
|
||||
expect(edges.length).toBeGreaterThan(0);
|
||||
expect(edges.every((e) => !e.animated)).toBe(true);
|
||||
});
|
||||
|
||||
test('handoff graph edges are animated', () => {
|
||||
const pattern = findPattern('handoff');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const edges = toCanvasEdges(graph, pattern.mode);
|
||||
|
||||
expect(edges.every((e) => e.animated)).toBe(true);
|
||||
});
|
||||
|
||||
test('group chat graph includes orchestrator system node', () => {
|
||||
const pattern = findPattern('group-chat');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const nodes = toCanvasNodes(graph, pattern.agents);
|
||||
|
||||
const orchestrator = nodes.find((n) => n.data.kind === 'orchestrator');
|
||||
expect(orchestrator).toBeDefined();
|
||||
expect(orchestrator!.data.readOnly).toBe(true);
|
||||
expect(orchestrator!.data.label).toBe('Orchestrator');
|
||||
});
|
||||
|
||||
test('findAgentForNode resolves agent from graph node id', () => {
|
||||
const pattern = findPattern('sequential');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
|
||||
const firstAgent = pattern.agents[0]!;
|
||||
const agent = findAgentForNode(`agent-node-${firstAgent.id}`, graph, pattern.agents);
|
||||
expect(agent).toBeDefined();
|
||||
expect(agent!.id).toBe(firstAgent.id);
|
||||
|
||||
const noAgent = findAgentForNode('system-user-input', graph, pattern.agents);
|
||||
expect(noAgent).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pattern graph connection rules', () => {
|
||||
test('sequential mode disallows all new connections', () => {
|
||||
const pattern = findPattern('sequential');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const agentNodes = graph.nodes.filter((n) => n.kind === 'agent');
|
||||
|
||||
const allowed = isConnectionAllowed(
|
||||
{ source: agentNodes[0]!.id, target: agentNodes[1]!.id, sourceHandle: null, targetHandle: null },
|
||||
'sequential',
|
||||
graph,
|
||||
);
|
||||
expect(allowed).toBe(false);
|
||||
});
|
||||
|
||||
test('handoff mode allows agent-to-agent connections', () => {
|
||||
const pattern = findPattern('handoff');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const agentNodes = graph.nodes.filter((n) => n.kind === 'agent');
|
||||
|
||||
const allowed = isConnectionAllowed(
|
||||
{ source: agentNodes[0]!.id, target: agentNodes[1]!.id, sourceHandle: null, targetHandle: null },
|
||||
'handoff',
|
||||
graph,
|
||||
);
|
||||
expect(allowed).toBe(true);
|
||||
});
|
||||
|
||||
test('handoff mode blocks system-to-agent connections', () => {
|
||||
const pattern = findPattern('handoff');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const inputNode = graph.nodes.find((n) => n.kind === 'user-input')!;
|
||||
const agentNode = graph.nodes.find((n) => n.kind === 'agent')!;
|
||||
|
||||
const allowed = isConnectionAllowed(
|
||||
{ source: inputNode.id, target: agentNode.id, sourceHandle: null, targetHandle: null },
|
||||
'handoff',
|
||||
graph,
|
||||
);
|
||||
expect(allowed).toBe(false);
|
||||
});
|
||||
|
||||
test('concurrent mode disallows all new connections', () => {
|
||||
const pattern = findPattern('concurrent');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const agentNodes = graph.nodes.filter((n) => n.kind === 'agent');
|
||||
|
||||
const allowed = isConnectionAllowed(
|
||||
{ source: agentNodes[0]!.id, target: agentNodes[1]!.id, sourceHandle: null, targetHandle: null },
|
||||
'concurrent',
|
||||
graph,
|
||||
);
|
||||
expect(allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pattern graph mutation helpers', () => {
|
||||
test('addHandoffEdge adds a new edge between agent nodes', () => {
|
||||
const pattern = findPattern('handoff');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const agentNodes = graph.nodes.filter((n) => n.kind === 'agent');
|
||||
const initialEdgeCount = graph.edges.length;
|
||||
|
||||
const updated = addHandoffEdge(graph, agentNodes[1]!.id, agentNodes[2]!.id);
|
||||
expect(updated.edges.length).toBe(initialEdgeCount + 1);
|
||||
|
||||
const duplicated = addHandoffEdge(updated, agentNodes[1]!.id, agentNodes[2]!.id);
|
||||
expect(duplicated.edges.length).toBe(initialEdgeCount + 1);
|
||||
});
|
||||
|
||||
test('removeEdge removes an edge by id', () => {
|
||||
const pattern = findPattern('handoff');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const firstEdge = graph.edges[0]!;
|
||||
|
||||
const updated = removeEdge(graph, firstEdge.id);
|
||||
expect(updated.edges.length).toBe(graph.edges.length - 1);
|
||||
expect(updated.edges.find((e) => e.id === firstEdge.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user