mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-08 12:48:48 +02:00
fix: address graph editor UX feedback
- Hide input handle on User Input nodes and output handle on User Output nodes (separate userInputNode/userOutputNode node types) - Enable edge deletion in handoff mode only (Delete key or React Flow UI); only agent-to-agent edges are deletable, structural edges are protected - Block all edge mutations (add/delete) in concurrent and group chat modes - Add agent as disconnected node without auto-rebuilding the graph - Add sequential reorder controls (↑↓) in the inspector panel with position/order swap and automatic edge rebuilding - Replace circular group chat layout with vertical column to prevent bidirectional edge crossings - Tighten handoff layout spacing so edges between triage and specialists don't overlap nodes - Use smoothstep edge type for cleaner edge routing across all modes - Add 6 new tests covering reorder, disconnected add, edge deletion rules, and node type assertions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -34,6 +34,7 @@ import {
|
|||||||
type WorkspaceToolingSettings,
|
type WorkspaceToolingSettings,
|
||||||
} from '@shared/domain/tooling';
|
} from '@shared/domain/tooling';
|
||||||
|
|
||||||
|
import { addAgentNodeToGraph } from '@renderer/lib/patternGraph';
|
||||||
import { PatternGraphCanvas } from './pattern-graph/PatternGraphCanvas';
|
import { PatternGraphCanvas } from './pattern-graph/PatternGraphCanvas';
|
||||||
import { PatternGraphInspector } from './pattern-graph/PatternGraphInspector';
|
import { PatternGraphInspector } from './pattern-graph/PatternGraphInspector';
|
||||||
|
|
||||||
@@ -149,20 +150,16 @@ export function PatternEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addAgent() {
|
function addAgent() {
|
||||||
emitChange({
|
const newAgent: PatternAgentDefinition = {
|
||||||
...pattern,
|
id: `agent-${crypto.randomUUID()}`,
|
||||||
agents: [
|
name: `Agent ${pattern.agents.length + 1}`,
|
||||||
...pattern.agents,
|
description: '',
|
||||||
{
|
instructions: '',
|
||||||
id: `agent-${crypto.randomUUID()}`,
|
model: 'gpt-5.4',
|
||||||
name: `Agent ${pattern.agents.length + 1}`,
|
reasoningEffort: 'high',
|
||||||
description: '',
|
};
|
||||||
instructions: '',
|
const updatedGraph = addAgentNodeToGraph(graph, newAgent);
|
||||||
model: 'gpt-5.4',
|
onChange({ ...pattern, agents: [...pattern.agents, newAgent], graph: updatedGraph });
|
||||||
reasoningEffort: 'high',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAgent(agentId: string, patch: Partial<PatternAgentDefinition>) {
|
function updateAgent(agentId: string, patch: Partial<PatternAgentDefinition>) {
|
||||||
@@ -474,9 +471,11 @@ export function PatternEditor({
|
|||||||
availableModels={availableModels}
|
availableModels={availableModels}
|
||||||
agents={pattern.agents}
|
agents={pattern.agents}
|
||||||
graph={graph}
|
graph={graph}
|
||||||
|
mode={pattern.mode}
|
||||||
selectedNodeId={selectedNodeId}
|
selectedNodeId={selectedNodeId}
|
||||||
onAgentChange={updateAgent}
|
onAgentChange={updateAgent}
|
||||||
onAgentRemove={removeAgent}
|
onAgentRemove={removeAgent}
|
||||||
|
onGraphChange={emitGraphChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,13 +52,44 @@ function GraphNodeContent({ data, selected }: { data: GraphNodeData; selected: b
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleStyles = {
|
||||||
|
system: '!size-2 !border-zinc-600 !bg-zinc-400',
|
||||||
|
agent: '!size-2 !border-indigo-400 !bg-indigo-500',
|
||||||
|
hidden: '!size-0 !border-0 !bg-transparent !min-w-0 !min-h-0',
|
||||||
|
};
|
||||||
|
|
||||||
|
/* user-input: source only (no incoming handle)
|
||||||
|
user-output: target only (no outgoing handle) */
|
||||||
|
|
||||||
|
export const UserInputNode = memo(function UserInputNode({ data, selected }: NodeProps) {
|
||||||
|
const nodeData = data as unknown as GraphNodeData;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Handle type="target" position={Position.Left} className={handleStyles.hidden} />
|
||||||
|
<GraphNodeContent data={nodeData} selected={selected ?? false} />
|
||||||
|
<Handle type="source" position={Position.Right} className={handleStyles.system} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UserOutputNode = memo(function UserOutputNode({ data, selected }: NodeProps) {
|
||||||
|
const nodeData = data as unknown as GraphNodeData;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Handle type="target" position={Position.Left} className={handleStyles.system} />
|
||||||
|
<GraphNodeContent data={nodeData} selected={selected ?? false} />
|
||||||
|
<Handle type="source" position={Position.Right} className={handleStyles.hidden} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export const SystemNode = memo(function SystemNode({ data, selected }: NodeProps) {
|
export const SystemNode = memo(function SystemNode({ data, selected }: NodeProps) {
|
||||||
const nodeData = data as unknown as GraphNodeData;
|
const nodeData = data as unknown as GraphNodeData;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Handle type="target" position={Position.Left} className="!size-2 !border-zinc-600 !bg-zinc-400" />
|
<Handle type="target" position={Position.Left} className={handleStyles.system} />
|
||||||
<GraphNodeContent data={nodeData} selected={selected ?? false} />
|
<GraphNodeContent data={nodeData} selected={selected ?? false} />
|
||||||
<Handle type="source" position={Position.Right} className="!size-2 !border-zinc-600 !bg-zinc-400" />
|
<Handle type="source" position={Position.Right} className={handleStyles.system} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -67,14 +98,16 @@ export const AgentNode = memo(function AgentNode({ data, selected }: NodeProps)
|
|||||||
const nodeData = data as unknown as GraphNodeData;
|
const nodeData = data as unknown as GraphNodeData;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Handle type="target" position={Position.Left} className="!size-2 !border-indigo-400 !bg-indigo-500" />
|
<Handle type="target" position={Position.Left} className={handleStyles.agent} />
|
||||||
<GraphNodeContent data={nodeData} selected={selected ?? false} />
|
<GraphNodeContent data={nodeData} selected={selected ?? false} />
|
||||||
<Handle type="source" position={Position.Right} className="!size-2 !border-indigo-400 !bg-indigo-500" />
|
<Handle type="source" position={Position.Right} className={handleStyles.agent} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const graphNodeTypes = {
|
export const graphNodeTypes = {
|
||||||
|
userInputNode: UserInputNode,
|
||||||
|
userOutputNode: UserOutputNode,
|
||||||
systemNode: SystemNode,
|
systemNode: SystemNode,
|
||||||
agentNode: AgentNode,
|
agentNode: AgentNode,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
Background,
|
Background,
|
||||||
@@ -6,17 +6,21 @@ import {
|
|||||||
useNodesState,
|
useNodesState,
|
||||||
useEdgesState,
|
useEdgesState,
|
||||||
type Node,
|
type Node,
|
||||||
|
type Edge,
|
||||||
type OnConnect,
|
type OnConnect,
|
||||||
|
type OnEdgesChange,
|
||||||
type OnNodesChange,
|
type OnNodesChange,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
|
|
||||||
import type { PatternDefinition, PatternGraph } from '@shared/domain/pattern';
|
import type { OrchestrationMode, PatternDefinition, PatternGraph } from '@shared/domain/pattern';
|
||||||
import { resolvePatternGraph } from '@shared/domain/pattern';
|
import { resolvePatternGraph } from '@shared/domain/pattern';
|
||||||
import {
|
import {
|
||||||
addHandoffEdge,
|
addHandoffEdge,
|
||||||
fromCanvasPositions,
|
fromCanvasPositions,
|
||||||
isConnectionAllowed,
|
isConnectionAllowed,
|
||||||
|
isEdgeDeletionAllowed,
|
||||||
|
removeEdge,
|
||||||
toCanvasEdges,
|
toCanvasEdges,
|
||||||
toCanvasNodes,
|
toCanvasNodes,
|
||||||
type GraphNodeData,
|
type GraphNodeData,
|
||||||
@@ -80,6 +84,37 @@ export function PatternGraphCanvas({
|
|||||||
[onNodesChange, pattern, onGraphChange, setNodes],
|
[onNodesChange, pattern, onGraphChange, setNodes],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleEdgesChange: OnEdgesChange = useCallback(
|
||||||
|
(changes) => {
|
||||||
|
// Block all edge deletions in modes that don't support it
|
||||||
|
if (!isEdgeDeletionAllowed(pattern.mode)) {
|
||||||
|
const filtered = changes.filter((c) => c.type !== 'remove');
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
onEdgesChange(filtered);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For handoff mode, apply removals to the authoritative graph
|
||||||
|
const removals = changes.filter((c) => c.type === 'remove');
|
||||||
|
if (removals.length > 0) {
|
||||||
|
let updatedGraph = graph;
|
||||||
|
for (const removal of removals) {
|
||||||
|
if (removal.type === 'remove') {
|
||||||
|
updatedGraph = removeEdge(updatedGraph, removal.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onGraphChange(updatedGraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonRemovals = changes.filter((c) => c.type !== 'remove');
|
||||||
|
if (nonRemovals.length > 0) {
|
||||||
|
onEdgesChange(nonRemovals);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onEdgesChange, pattern.mode, graph, onGraphChange],
|
||||||
|
);
|
||||||
|
|
||||||
const handleConnect: OnConnect = useCallback(
|
const handleConnect: OnConnect = useCallback(
|
||||||
(connection) => {
|
(connection) => {
|
||||||
if (!isConnectionAllowed(connection, pattern.mode, graph)) {
|
if (!isConnectionAllowed(connection, pattern.mode, graph)) {
|
||||||
@@ -114,7 +149,7 @@ export function PatternGraphCanvas({
|
|||||||
}))}
|
}))}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
onNodesChange={handleNodesChange}
|
onNodesChange={handleNodesChange}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={handleEdgesChange}
|
||||||
onConnect={handleConnect}
|
onConnect={handleConnect}
|
||||||
onNodeClick={handleNodeClick}
|
onNodeClick={handleNodeClick}
|
||||||
onPaneClick={handlePaneClick}
|
onPaneClick={handlePaneClick}
|
||||||
@@ -125,9 +160,11 @@ export function PatternGraphCanvas({
|
|||||||
maxZoom={2}
|
maxZoom={2}
|
||||||
proOptions={{ hideAttribution: true }}
|
proOptions={{ hideAttribution: true }}
|
||||||
defaultEdgeOptions={{
|
defaultEdgeOptions={{
|
||||||
|
type: 'smoothstep',
|
||||||
style: { stroke: '#52525b', strokeWidth: 1.5 },
|
style: { stroke: '#52525b', strokeWidth: 1.5 },
|
||||||
}}
|
}}
|
||||||
connectionLineStyle={{ stroke: '#6366f1', strokeWidth: 1.5 }}
|
connectionLineStyle={{ stroke: '#6366f1', strokeWidth: 1.5 }}
|
||||||
|
deleteKeyCode="Delete"
|
||||||
>
|
>
|
||||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#27272a" />
|
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#27272a" />
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Bot, CircleUser, Layers, Plus, Radio, Shuffle, Trash2 } from 'lucide-react';
|
import { Bot, ChevronDown, ChevronUp, CircleUser, Layers, Radio, Shuffle, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
findModel,
|
findModel,
|
||||||
@@ -6,17 +6,28 @@ import {
|
|||||||
resolveReasoningEffort,
|
resolveReasoningEffort,
|
||||||
type ModelDefinition,
|
type ModelDefinition,
|
||||||
} from '@shared/domain/models';
|
} from '@shared/domain/models';
|
||||||
import type { PatternAgentDefinition, PatternGraph, PatternGraphNodeKind } from '@shared/domain/pattern';
|
import type {
|
||||||
import { findAgentForNode } from '@renderer/lib/patternGraph';
|
OrchestrationMode,
|
||||||
|
PatternAgentDefinition,
|
||||||
|
PatternGraph,
|
||||||
|
PatternGraphNodeKind,
|
||||||
|
} from '@shared/domain/pattern';
|
||||||
|
import {
|
||||||
|
canMoveSequential,
|
||||||
|
findAgentForNode,
|
||||||
|
swapSequentialOrder,
|
||||||
|
} from '@renderer/lib/patternGraph';
|
||||||
import { ModelSelect, ReasoningEffortSelect } from '../AgentConfigFields';
|
import { ModelSelect, ReasoningEffortSelect } from '../AgentConfigFields';
|
||||||
|
|
||||||
interface PatternGraphInspectorProps {
|
interface PatternGraphInspectorProps {
|
||||||
availableModels: ReadonlyArray<ModelDefinition>;
|
availableModels: ReadonlyArray<ModelDefinition>;
|
||||||
agents: PatternAgentDefinition[];
|
agents: PatternAgentDefinition[];
|
||||||
graph: PatternGraph;
|
graph: PatternGraph;
|
||||||
|
mode: OrchestrationMode;
|
||||||
selectedNodeId: string | null;
|
selectedNodeId: string | null;
|
||||||
onAgentChange: (agentId: string, patch: Partial<PatternAgentDefinition>) => void;
|
onAgentChange: (agentId: string, patch: Partial<PatternAgentDefinition>) => void;
|
||||||
onAgentRemove: (agentId: string) => void;
|
onAgentRemove: (agentId: string) => void;
|
||||||
|
onGraphChange: (graph: PatternGraph) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function InputField({
|
function InputField({
|
||||||
@@ -104,15 +115,26 @@ function SystemNodeInspector({ kind }: { kind: PatternGraphNodeKind }) {
|
|||||||
function AgentNodeInspector({
|
function AgentNodeInspector({
|
||||||
agent,
|
agent,
|
||||||
availableModels,
|
availableModels,
|
||||||
|
mode,
|
||||||
|
graph,
|
||||||
|
nodeId,
|
||||||
onAgentChange,
|
onAgentChange,
|
||||||
onAgentRemove,
|
onAgentRemove,
|
||||||
|
onGraphChange,
|
||||||
}: {
|
}: {
|
||||||
agent: PatternAgentDefinition;
|
agent: PatternAgentDefinition;
|
||||||
availableModels: ReadonlyArray<ModelDefinition>;
|
availableModels: ReadonlyArray<ModelDefinition>;
|
||||||
|
mode: OrchestrationMode;
|
||||||
|
graph: PatternGraph;
|
||||||
|
nodeId: string;
|
||||||
onAgentChange: (agentId: string, patch: Partial<PatternAgentDefinition>) => void;
|
onAgentChange: (agentId: string, patch: Partial<PatternAgentDefinition>) => void;
|
||||||
onAgentRemove: (agentId: string) => void;
|
onAgentRemove: (agentId: string) => void;
|
||||||
|
onGraphChange: (graph: PatternGraph) => void;
|
||||||
}) {
|
}) {
|
||||||
const model = findModel(agent.model, availableModels);
|
const model = findModel(agent.model, availableModels);
|
||||||
|
const showReorder = mode === 'sequential' || mode === 'single' || mode === 'magentic';
|
||||||
|
const canUp = showReorder && canMoveSequential(graph, nodeId, 'up');
|
||||||
|
const canDown = showReorder && canMoveSequential(graph, nodeId, 'down');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -123,14 +145,37 @@ function AgentNodeInspector({
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-[13px] font-semibold text-zinc-200">{agent.name || 'Unnamed'}</div>
|
<div className="text-[13px] font-semibold text-zinc-200">{agent.name || 'Unnamed'}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-1">
|
||||||
className="flex items-center gap-1 text-[12px] text-zinc-600 transition hover:text-red-400"
|
{showReorder && (
|
||||||
onClick={() => onAgentRemove(agent.id)}
|
<>
|
||||||
type="button"
|
<button
|
||||||
>
|
className="flex size-6 items-center justify-center rounded text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-300 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-zinc-500"
|
||||||
<Trash2 className="size-3" />
|
disabled={!canUp}
|
||||||
Remove
|
onClick={() => onGraphChange(swapSequentialOrder(graph, nodeId, 'up'))}
|
||||||
</button>
|
title="Move earlier in sequence"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronUp className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex size-6 items-center justify-center rounded text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-300 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-zinc-500"
|
||||||
|
disabled={!canDown}
|
||||||
|
onClick={() => onGraphChange(swapSequentialOrder(graph, nodeId, 'down'))}
|
||||||
|
title="Move later in sequence"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronDown className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<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" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InputField
|
<InputField
|
||||||
@@ -181,9 +226,11 @@ export function PatternGraphInspector({
|
|||||||
availableModels,
|
availableModels,
|
||||||
agents,
|
agents,
|
||||||
graph,
|
graph,
|
||||||
|
mode,
|
||||||
selectedNodeId,
|
selectedNodeId,
|
||||||
onAgentChange,
|
onAgentChange,
|
||||||
onAgentRemove,
|
onAgentRemove,
|
||||||
|
onGraphChange,
|
||||||
}: PatternGraphInspectorProps) {
|
}: PatternGraphInspectorProps) {
|
||||||
if (!selectedNodeId) {
|
if (!selectedNodeId) {
|
||||||
return (
|
return (
|
||||||
@@ -218,8 +265,12 @@ export function PatternGraphInspector({
|
|||||||
<AgentNodeInspector
|
<AgentNodeInspector
|
||||||
agent={agent}
|
agent={agent}
|
||||||
availableModels={availableModels}
|
availableModels={availableModels}
|
||||||
|
mode={mode}
|
||||||
|
graph={graph}
|
||||||
|
nodeId={selectedNodeId}
|
||||||
onAgentChange={onAgentChange}
|
onAgentChange={onAgentChange}
|
||||||
onAgentRemove={onAgentRemove}
|
onAgentRemove={onAgentRemove}
|
||||||
|
onGraphChange={onGraphChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,11 +45,16 @@ function resolveNodeLabel(node: PatternGraphNode, agents: PatternAgentDefinition
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveNodeType(kind: PatternGraphNodeKind): string {
|
function resolveNodeType(kind: PatternGraphNodeKind): string {
|
||||||
if (kind === 'agent') {
|
switch (kind) {
|
||||||
return 'agentNode';
|
case 'user-input':
|
||||||
|
return 'userInputNode';
|
||||||
|
case 'user-output':
|
||||||
|
return 'userOutputNode';
|
||||||
|
case 'agent':
|
||||||
|
return 'agentNode';
|
||||||
|
default:
|
||||||
|
return 'systemNode';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'systemNode';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toCanvasNodes(graph: PatternGraph, agents: PatternAgentDefinition[]): Node<GraphNodeData>[] {
|
export function toCanvasNodes(graph: PatternGraph, agents: PatternAgentDefinition[]): Node<GraphNodeData>[] {
|
||||||
@@ -70,14 +75,28 @@ export function toCanvasNodes(graph: PatternGraph, agents: PatternAgentDefinitio
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Determines whether user-created edges can be deleted in this mode. */
|
||||||
|
function isEdgeDeletable(edge: PatternGraphEdge, mode: OrchestrationMode, graph: PatternGraph): boolean {
|
||||||
|
if (mode !== 'handoff') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceNode = graph.nodes.find((n) => n.id === edge.source);
|
||||||
|
const targetNode = graph.nodes.find((n) => n.id === edge.target);
|
||||||
|
|
||||||
|
// Only agent↔agent edges in handoff mode are user-deletable;
|
||||||
|
// system edges (user-input → triage, agent → user-output) are structural.
|
||||||
|
return sourceNode?.kind === 'agent' && targetNode?.kind === 'agent';
|
||||||
|
}
|
||||||
|
|
||||||
export function toCanvasEdges(graph: PatternGraph, mode: OrchestrationMode): Edge[] {
|
export function toCanvasEdges(graph: PatternGraph, mode: OrchestrationMode): Edge[] {
|
||||||
return graph.edges.map((edge) => ({
|
return graph.edges.map((edge) => ({
|
||||||
id: edge.id,
|
id: edge.id,
|
||||||
source: edge.source,
|
source: edge.source,
|
||||||
target: edge.target,
|
target: edge.target,
|
||||||
type: 'default',
|
type: 'smoothstep',
|
||||||
animated: mode === 'handoff',
|
animated: mode === 'handoff',
|
||||||
deletable: false,
|
deletable: isEdgeDeletable(edge, mode, graph),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +153,11 @@ export function isConnectionAllowed(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether edges can be deleted by the user in this mode. */
|
||||||
|
export function isEdgeDeletionAllowed(mode: OrchestrationMode): boolean {
|
||||||
|
return mode === 'handoff';
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Graph mutation helpers ─────────────────────────────────── */
|
/* ── Graph mutation helpers ─────────────────────────────────── */
|
||||||
|
|
||||||
function edgeId(source: string, target: string): string {
|
function edgeId(source: string, target: string): string {
|
||||||
@@ -158,6 +182,106 @@ export function removeEdge(graph: PatternGraph, removeEdgeId: string): PatternGr
|
|||||||
return { ...graph, edges: graph.edges.filter((e) => e.id !== removeEdgeId) };
|
return { ...graph, edges: graph.edges.filter((e) => e.id !== removeEdgeId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Add disconnected agent node ───────────────────────────── */
|
||||||
|
|
||||||
|
export function addAgentNodeToGraph(
|
||||||
|
graph: PatternGraph,
|
||||||
|
agent: PatternAgentDefinition,
|
||||||
|
): PatternGraph {
|
||||||
|
const existingAgentNodes = graph.nodes.filter((n) => n.kind === 'agent');
|
||||||
|
const nextOrder = existingAgentNodes.length;
|
||||||
|
|
||||||
|
// Place new node below existing agent nodes
|
||||||
|
const maxY = existingAgentNodes.reduce((max, n) => Math.max(max, n.position.y), 0);
|
||||||
|
const avgX = existingAgentNodes.length > 0
|
||||||
|
? Math.round(existingAgentNodes.reduce((sum, n) => sum + n.position.x, 0) / existingAgentNodes.length)
|
||||||
|
: 400;
|
||||||
|
|
||||||
|
const newNode: PatternGraphNode = {
|
||||||
|
id: `agent-node-${agent.id}`,
|
||||||
|
kind: 'agent',
|
||||||
|
agentId: agent.id,
|
||||||
|
order: nextOrder,
|
||||||
|
position: { x: avgX, y: maxY + 120 },
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...graph, nodes: [...graph.nodes, newNode] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sequential reorder ────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function swapSequentialOrder(
|
||||||
|
graph: PatternGraph,
|
||||||
|
agentNodeId: string,
|
||||||
|
direction: 'up' | 'down',
|
||||||
|
): PatternGraph {
|
||||||
|
const agentNodes = graph.nodes
|
||||||
|
.filter((n) => n.kind === 'agent')
|
||||||
|
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||||
|
|
||||||
|
const currentIndex = agentNodes.findIndex((n) => n.id === agentNodeId);
|
||||||
|
if (currentIndex < 0) {
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
const swapIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||||
|
if (swapIndex < 0 || swapIndex >= agentNodes.length) {
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentNode = agentNodes[currentIndex]!;
|
||||||
|
const swapNode = agentNodes[swapIndex]!;
|
||||||
|
|
||||||
|
// Swap order values
|
||||||
|
const updatedNodes = graph.nodes.map((n) => {
|
||||||
|
if (n.id === currentNode.id) {
|
||||||
|
return { ...n, order: swapNode.order, position: { ...swapNode.position } };
|
||||||
|
}
|
||||||
|
if (n.id === swapNode.id) {
|
||||||
|
return { ...n, order: currentNode.order, position: { ...currentNode.position } };
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rebuild linear edges: input → agent₁ → agent₂ → ... → output
|
||||||
|
const inputNode = graph.nodes.find((n) => n.kind === 'user-input');
|
||||||
|
const outputNode = graph.nodes.find((n) => n.kind === 'user-output');
|
||||||
|
const sortedAgents = updatedNodes
|
||||||
|
.filter((n) => n.kind === 'agent')
|
||||||
|
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||||
|
|
||||||
|
const path = [
|
||||||
|
...(inputNode ? [inputNode.id] : []),
|
||||||
|
...sortedAgents.map((n) => n.id),
|
||||||
|
...(outputNode ? [outputNode.id] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const newEdges: PatternGraphEdge[] = [];
|
||||||
|
for (let i = 0; i < path.length - 1; i += 1) {
|
||||||
|
newEdges.push({ id: edgeId(path[i]!, path[i + 1]!), source: path[i]!, target: path[i + 1]! });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nodes: updatedNodes, edges: newEdges };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if an agent node can be moved up or down in sequential order. */
|
||||||
|
export function canMoveSequential(
|
||||||
|
graph: PatternGraph,
|
||||||
|
agentNodeId: string,
|
||||||
|
direction: 'up' | 'down',
|
||||||
|
): boolean {
|
||||||
|
const agentNodes = graph.nodes
|
||||||
|
.filter((n) => n.kind === 'agent')
|
||||||
|
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||||
|
|
||||||
|
const index = agentNodes.findIndex((n) => n.id === agentNodeId);
|
||||||
|
if (index < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return direction === 'up' ? index > 0 : index < agentNodes.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Agent node for selected inspector ─────────────────────── */
|
/* ── Agent node for selected inspector ─────────────────────── */
|
||||||
|
|
||||||
export function findAgentForNode(
|
export function findAgentForNode(
|
||||||
|
|||||||
@@ -210,14 +210,20 @@ function createHandoffGraph(agents: PatternAgentDefinition[]): PatternGraph {
|
|||||||
const outputNode: PatternGraphNode = {
|
const outputNode: PatternGraphNode = {
|
||||||
id: SYSTEM_NODE_IDS.userOutput,
|
id: SYSTEM_NODE_IDS.userOutput,
|
||||||
kind: 'user-output',
|
kind: 'user-output',
|
||||||
position: { x: 860, y: 0 },
|
position: { x: 700, y: 0 },
|
||||||
};
|
};
|
||||||
const entryAgent = agents[0];
|
const entryAgent = agents[0];
|
||||||
|
const specialistCount = Math.max(agents.length - 1, 1);
|
||||||
const entryNode = entryAgent
|
const entryNode = entryAgent
|
||||||
? createAgentNode(entryAgent, 0, { x: 220, y: 0 })
|
? createAgentNode(entryAgent, 0, { x: 200, y: 0 })
|
||||||
: undefined;
|
: undefined;
|
||||||
|
// Place specialists in a vertical column with enough spacing so
|
||||||
|
// bidirectional edges to/from the triage agent don't overlap.
|
||||||
const specialistNodes = agents.slice(1).map((agent, index) =>
|
const specialistNodes = agents.slice(1).map((agent, index) =>
|
||||||
createAgentNode(agent, index + 1, { x: 540, y: spreadY(index, Math.max(agents.length - 1, 1), 220) }),
|
createAgentNode(agent, index + 1, {
|
||||||
|
x: 460,
|
||||||
|
y: spreadY(index, specialistCount, 150),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
const nodes = [inputNode, ...(entryNode ? [entryNode] : []), ...specialistNodes, outputNode];
|
const nodes = [inputNode, ...(entryNode ? [entryNode] : []), ...specialistNodes, outputNode];
|
||||||
const edges: PatternGraphEdge[] = [];
|
const edges: PatternGraphEdge[] = [];
|
||||||
@@ -245,24 +251,21 @@ function createGroupChatGraph(agents: PatternAgentDefinition[]): PatternGraph {
|
|||||||
const orchestratorNode: PatternGraphNode = {
|
const orchestratorNode: PatternGraphNode = {
|
||||||
id: SYSTEM_NODE_IDS.orchestrator,
|
id: SYSTEM_NODE_IDS.orchestrator,
|
||||||
kind: 'orchestrator',
|
kind: 'orchestrator',
|
||||||
position: { x: 250, y: 0 },
|
position: { x: 200, y: 0 },
|
||||||
};
|
};
|
||||||
const outputNode: PatternGraphNode = {
|
const outputNode: PatternGraphNode = {
|
||||||
id: SYSTEM_NODE_IDS.userOutput,
|
id: SYSTEM_NODE_IDS.userOutput,
|
||||||
kind: 'user-output',
|
kind: 'user-output',
|
||||||
position: { x: 900, y: 0 },
|
position: { x: 660, y: 0 },
|
||||||
};
|
};
|
||||||
const centerX = 560;
|
// Place agents in a vertical column to the right of the orchestrator.
|
||||||
const centerY = 0;
|
// This avoids crossing edges from the bidirectional orchestrator↔agent links.
|
||||||
const radiusX = 190;
|
const agentNodes = agents.map((agent, index) =>
|
||||||
const radiusY = 170;
|
createAgentNode(agent, index, {
|
||||||
const agentNodes = agents.map((agent, index) => {
|
x: 440,
|
||||||
const angle = agents.length <= 1 ? 0 : (Math.PI * 2 * index) / agents.length - Math.PI / 2;
|
y: spreadY(index, Math.max(agents.length, 1), 130),
|
||||||
return createAgentNode(agent, index, {
|
}),
|
||||||
x: Math.round(centerX + Math.cos(angle) * radiusX),
|
);
|
||||||
y: Math.round(centerY + Math.sin(angle) * radiusY),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nodes: [inputNode, orchestratorNode, ...agentNodes, outputNode],
|
nodes: [inputNode, orchestratorNode, ...agentNodes, outputNode],
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import { describe, expect, test } from 'bun:test';
|
|||||||
|
|
||||||
import { createBuiltinPatterns, resolvePatternGraph, type PatternDefinition } from '@shared/domain/pattern';
|
import { createBuiltinPatterns, resolvePatternGraph, type PatternDefinition } from '@shared/domain/pattern';
|
||||||
import {
|
import {
|
||||||
|
addAgentNodeToGraph,
|
||||||
addHandoffEdge,
|
addHandoffEdge,
|
||||||
|
canMoveSequential,
|
||||||
findAgentForNode,
|
findAgentForNode,
|
||||||
isConnectionAllowed,
|
isConnectionAllowed,
|
||||||
|
isEdgeDeletionAllowed,
|
||||||
removeEdge,
|
removeEdge,
|
||||||
|
swapSequentialOrder,
|
||||||
toCanvasEdges,
|
toCanvasEdges,
|
||||||
toCanvasNodes,
|
toCanvasNodes,
|
||||||
} from '@renderer/lib/patternGraph';
|
} from '@renderer/lib/patternGraph';
|
||||||
@@ -31,7 +35,7 @@ describe('pattern graph view model', () => {
|
|||||||
expect(userOutput).toBeDefined();
|
expect(userOutput).toBeDefined();
|
||||||
expect(userInput!.data.readOnly).toBe(true);
|
expect(userInput!.data.readOnly).toBe(true);
|
||||||
expect(userOutput!.data.readOnly).toBe(true);
|
expect(userOutput!.data.readOnly).toBe(true);
|
||||||
expect(userInput!.type).toBe('systemNode');
|
expect(userInput!.type).toBe('userInputNode');
|
||||||
|
|
||||||
const agentNodes = nodes.filter((n) => n.data.kind === 'agent');
|
const agentNodes = nodes.filter((n) => n.data.kind === 'agent');
|
||||||
expect(agentNodes.length).toBe(pattern.agents.length);
|
expect(agentNodes.length).toBe(pattern.agents.length);
|
||||||
@@ -168,4 +172,90 @@ describe('pattern graph mutation helpers', () => {
|
|||||||
expect(updated.edges.length).toBe(graph.edges.length - 1);
|
expect(updated.edges.length).toBe(graph.edges.length - 1);
|
||||||
expect(updated.edges.find((e) => e.id === firstEdge.id)).toBeUndefined();
|
expect(updated.edges.find((e) => e.id === firstEdge.id)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('addAgentNodeToGraph places a disconnected agent node on the canvas', () => {
|
||||||
|
const pattern = findPattern('sequential');
|
||||||
|
const graph = resolvePatternGraph(pattern);
|
||||||
|
const newAgent = { id: 'new-1', name: 'New Agent', description: '', instructions: '', model: 'gpt-5.4' };
|
||||||
|
|
||||||
|
const updated = addAgentNodeToGraph(graph, newAgent);
|
||||||
|
expect(updated.nodes.length).toBe(graph.nodes.length + 1);
|
||||||
|
|
||||||
|
const newNode = updated.nodes.find((n) => n.agentId === 'new-1');
|
||||||
|
expect(newNode).toBeDefined();
|
||||||
|
expect(newNode!.kind).toBe('agent');
|
||||||
|
|
||||||
|
// No new edges added — node is disconnected
|
||||||
|
expect(updated.edges.length).toBe(graph.edges.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sequential reorder', () => {
|
||||||
|
test('swapSequentialOrder swaps two adjacent agents and rebuilds edges', () => {
|
||||||
|
const pattern = findPattern('sequential');
|
||||||
|
const graph = resolvePatternGraph(pattern);
|
||||||
|
const agentNodes = graph.nodes.filter((n) => n.kind === 'agent').sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||||
|
|
||||||
|
const firstId = agentNodes[0]!.id;
|
||||||
|
const secondId = agentNodes[1]!.id;
|
||||||
|
|
||||||
|
const swapped = swapSequentialOrder(graph, firstId, 'down');
|
||||||
|
const newFirst = swapped.nodes.find((n) => n.id === firstId)!;
|
||||||
|
const newSecond = swapped.nodes.find((n) => n.id === secondId)!;
|
||||||
|
|
||||||
|
expect(newFirst.order).toBe(1);
|
||||||
|
expect(newSecond.order).toBe(0);
|
||||||
|
|
||||||
|
// Verify linear edges still form a valid chain
|
||||||
|
const sortedAgents = swapped.nodes.filter((n) => n.kind === 'agent').sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||||
|
expect(sortedAgents[0]!.id).toBe(secondId);
|
||||||
|
expect(sortedAgents[1]!.id).toBe(firstId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canMoveSequential respects boundary conditions', () => {
|
||||||
|
const pattern = findPattern('sequential');
|
||||||
|
const graph = resolvePatternGraph(pattern);
|
||||||
|
const agentNodes = graph.nodes.filter((n) => n.kind === 'agent').sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||||
|
|
||||||
|
expect(canMoveSequential(graph, agentNodes[0]!.id, 'up')).toBe(false);
|
||||||
|
expect(canMoveSequential(graph, agentNodes[0]!.id, 'down')).toBe(true);
|
||||||
|
expect(canMoveSequential(graph, agentNodes[agentNodes.length - 1]!.id, 'down')).toBe(false);
|
||||||
|
expect(canMoveSequential(graph, agentNodes[agentNodes.length - 1]!.id, 'up')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge deletion rules', () => {
|
||||||
|
test('only handoff mode allows edge deletion', () => {
|
||||||
|
expect(isEdgeDeletionAllowed('handoff')).toBe(true);
|
||||||
|
expect(isEdgeDeletionAllowed('sequential')).toBe(false);
|
||||||
|
expect(isEdgeDeletionAllowed('concurrent')).toBe(false);
|
||||||
|
expect(isEdgeDeletionAllowed('group-chat')).toBe(false);
|
||||||
|
expect(isEdgeDeletionAllowed('single')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handoff canvas edges mark only agent-to-agent edges as deletable', () => {
|
||||||
|
const pattern = findPattern('handoff');
|
||||||
|
const graph = resolvePatternGraph(pattern);
|
||||||
|
const edges = toCanvasEdges(graph, 'handoff');
|
||||||
|
|
||||||
|
const deletableEdges = edges.filter((e) => e.deletable);
|
||||||
|
const nonDeletableEdges = edges.filter((e) => !e.deletable);
|
||||||
|
|
||||||
|
// Agent-to-agent edges should be deletable
|
||||||
|
expect(deletableEdges.length).toBeGreaterThan(0);
|
||||||
|
// Structural edges (user-input → triage, agent → user-output) should not
|
||||||
|
expect(nonDeletableEdges.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user-input nodes use userInputNode type and user-output use userOutputNode type', () => {
|
||||||
|
const pattern = findPattern('sequential');
|
||||||
|
const graph = resolvePatternGraph(pattern);
|
||||||
|
const nodes = toCanvasNodes(graph, pattern.agents);
|
||||||
|
|
||||||
|
const inputNode = nodes.find((n) => n.data.kind === 'user-input');
|
||||||
|
const outputNode = nodes.find((n) => n.data.kind === 'user-output');
|
||||||
|
|
||||||
|
expect(inputNode!.type).toBe('userInputNode');
|
||||||
|
expect(outputNode!.type).toBe('userOutputNode');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user