mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-04 02:48:44 +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,
|
||||
} from '@shared/domain/tooling';
|
||||
|
||||
import { addAgentNodeToGraph } from '@renderer/lib/patternGraph';
|
||||
import { PatternGraphCanvas } from './pattern-graph/PatternGraphCanvas';
|
||||
import { PatternGraphInspector } from './pattern-graph/PatternGraphInspector';
|
||||
|
||||
@@ -149,20 +150,16 @@ export function PatternEditor({
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
],
|
||||
});
|
||||
const newAgent: PatternAgentDefinition = {
|
||||
id: `agent-${crypto.randomUUID()}`,
|
||||
name: `Agent ${pattern.agents.length + 1}`,
|
||||
description: '',
|
||||
instructions: '',
|
||||
model: 'gpt-5.4',
|
||||
reasoningEffort: 'high',
|
||||
};
|
||||
const updatedGraph = addAgentNodeToGraph(graph, newAgent);
|
||||
onChange({ ...pattern, agents: [...pattern.agents, newAgent], graph: updatedGraph });
|
||||
}
|
||||
|
||||
function updateAgent(agentId: string, patch: Partial<PatternAgentDefinition>) {
|
||||
@@ -474,9 +471,11 @@ export function PatternEditor({
|
||||
availableModels={availableModels}
|
||||
agents={pattern.agents}
|
||||
graph={graph}
|
||||
mode={pattern.mode}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onAgentChange={updateAgent}
|
||||
onAgentRemove={removeAgent}
|
||||
onGraphChange={emitGraphChange}
|
||||
/>
|
||||
</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) {
|
||||
const nodeData = data as unknown as GraphNodeData;
|
||||
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} />
|
||||
<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;
|
||||
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} />
|
||||
<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 = {
|
||||
userInputNode: UserInputNode,
|
||||
userOutputNode: UserOutputNode,
|
||||
systemNode: SystemNode,
|
||||
agentNode: AgentNode,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
@@ -6,17 +6,21 @@ import {
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
type Node,
|
||||
type Edge,
|
||||
type OnConnect,
|
||||
type OnEdgesChange,
|
||||
type OnNodesChange,
|
||||
} from '@xyflow/react';
|
||||
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 {
|
||||
addHandoffEdge,
|
||||
fromCanvasPositions,
|
||||
isConnectionAllowed,
|
||||
isEdgeDeletionAllowed,
|
||||
removeEdge,
|
||||
toCanvasEdges,
|
||||
toCanvasNodes,
|
||||
type GraphNodeData,
|
||||
@@ -80,6 +84,37 @@ export function PatternGraphCanvas({
|
||||
[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(
|
||||
(connection) => {
|
||||
if (!isConnectionAllowed(connection, pattern.mode, graph)) {
|
||||
@@ -114,7 +149,7 @@ export function PatternGraphCanvas({
|
||||
}))}
|
||||
edges={edges}
|
||||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onEdgesChange={handleEdgesChange}
|
||||
onConnect={handleConnect}
|
||||
onNodeClick={handleNodeClick}
|
||||
onPaneClick={handlePaneClick}
|
||||
@@ -125,9 +160,11 @@ export function PatternGraphCanvas({
|
||||
maxZoom={2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
defaultEdgeOptions={{
|
||||
type: 'smoothstep',
|
||||
style: { stroke: '#52525b', strokeWidth: 1.5 },
|
||||
}}
|
||||
connectionLineStyle={{ stroke: '#6366f1', strokeWidth: 1.5 }}
|
||||
deleteKeyCode="Delete"
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#27272a" />
|
||||
</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 {
|
||||
findModel,
|
||||
@@ -6,17 +6,28 @@ import {
|
||||
resolveReasoningEffort,
|
||||
type ModelDefinition,
|
||||
} from '@shared/domain/models';
|
||||
import type { PatternAgentDefinition, PatternGraph, PatternGraphNodeKind } from '@shared/domain/pattern';
|
||||
import { findAgentForNode } from '@renderer/lib/patternGraph';
|
||||
import type {
|
||||
OrchestrationMode,
|
||||
PatternAgentDefinition,
|
||||
PatternGraph,
|
||||
PatternGraphNodeKind,
|
||||
} from '@shared/domain/pattern';
|
||||
import {
|
||||
canMoveSequential,
|
||||
findAgentForNode,
|
||||
swapSequentialOrder,
|
||||
} from '@renderer/lib/patternGraph';
|
||||
import { ModelSelect, ReasoningEffortSelect } from '../AgentConfigFields';
|
||||
|
||||
interface PatternGraphInspectorProps {
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
agents: PatternAgentDefinition[];
|
||||
graph: PatternGraph;
|
||||
mode: OrchestrationMode;
|
||||
selectedNodeId: string | null;
|
||||
onAgentChange: (agentId: string, patch: Partial<PatternAgentDefinition>) => void;
|
||||
onAgentRemove: (agentId: string) => void;
|
||||
onGraphChange: (graph: PatternGraph) => void;
|
||||
}
|
||||
|
||||
function InputField({
|
||||
@@ -104,15 +115,26 @@ function SystemNodeInspector({ kind }: { kind: PatternGraphNodeKind }) {
|
||||
function AgentNodeInspector({
|
||||
agent,
|
||||
availableModels,
|
||||
mode,
|
||||
graph,
|
||||
nodeId,
|
||||
onAgentChange,
|
||||
onAgentRemove,
|
||||
onGraphChange,
|
||||
}: {
|
||||
agent: PatternAgentDefinition;
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
mode: OrchestrationMode;
|
||||
graph: PatternGraph;
|
||||
nodeId: string;
|
||||
onAgentChange: (agentId: string, patch: Partial<PatternAgentDefinition>) => void;
|
||||
onAgentRemove: (agentId: string) => void;
|
||||
onGraphChange: (graph: PatternGraph) => void;
|
||||
}) {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
@@ -123,14 +145,37 @@ function AgentNodeInspector({
|
||||
</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 className="flex items-center gap-1">
|
||||
{showReorder && (
|
||||
<>
|
||||
<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={!canUp}
|
||||
onClick={() => onGraphChange(swapSequentialOrder(graph, nodeId, 'up'))}
|
||||
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>
|
||||
|
||||
<InputField
|
||||
@@ -181,9 +226,11 @@ export function PatternGraphInspector({
|
||||
availableModels,
|
||||
agents,
|
||||
graph,
|
||||
mode,
|
||||
selectedNodeId,
|
||||
onAgentChange,
|
||||
onAgentRemove,
|
||||
onGraphChange,
|
||||
}: PatternGraphInspectorProps) {
|
||||
if (!selectedNodeId) {
|
||||
return (
|
||||
@@ -218,8 +265,12 @@ export function PatternGraphInspector({
|
||||
<AgentNodeInspector
|
||||
agent={agent}
|
||||
availableModels={availableModels}
|
||||
mode={mode}
|
||||
graph={graph}
|
||||
nodeId={selectedNodeId}
|
||||
onAgentChange={onAgentChange}
|
||||
onAgentRemove={onAgentRemove}
|
||||
onGraphChange={onGraphChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user