feat: directional edges and provider icons on agent nodes

- Add ArrowClosed markers to all graph edges for clear directional flow
- Replace generic Bot icon with AI provider logos (OpenAI, Anthropic,
  Google) on agent nodes, inferred from the agent's model id
- Show model display name as subtitle on agent nodes (e.g. 'GPT-5.4',
  'Claude Opus 4.5')
- Thread availableModels catalog from PatternEditor through canvas to
  resolve friendly model names; falls back to raw model id
- Add 3 new tests for arrow markers, provider icons, and model labels

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-24 23:16:04 +01:00
co-authored by Copilot
parent c6c8e79e9f
commit eb2c8450e3
5 changed files with 110 additions and 26 deletions
@@ -318,6 +318,7 @@ export function PatternEditor({
<div className="min-h-[300px] flex-1 px-5 py-3"> <div className="min-h-[300px] flex-1 px-5 py-3">
<PatternGraphCanvas <PatternGraphCanvas
pattern={pattern} pattern={pattern}
availableModels={availableModels}
onGraphChange={emitGraphChange} onGraphChange={emitGraphChange}
onNodeSelect={setSelectedNodeId} onNodeSelect={setSelectedNodeId}
selectedNodeId={selectedNodeId} selectedNodeId={selectedNodeId}
@@ -1,14 +1,15 @@
import { memo } from 'react'; import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react'; import { Handle, Position, type NodeProps } from '@xyflow/react';
import { CircleUser, Bot, Shuffle, Layers, Radio } from 'lucide-react'; import { CircleUser, Shuffle, Layers, Radio, Bot } from 'lucide-react';
import type { GraphNodeData } from '@renderer/lib/patternGraph'; import type { GraphNodeData } from '@renderer/lib/patternGraph';
import type { PatternGraphNodeKind } from '@shared/domain/pattern'; import type { PatternGraphNodeKind } from '@shared/domain/pattern';
import { ProviderIcon } from '@renderer/components/ProviderIcons';
const kindIcons: Record<PatternGraphNodeKind, typeof CircleUser> = { const kindIcons: Record<PatternGraphNodeKind, typeof CircleUser> = {
'user-input': CircleUser, 'user-input': CircleUser,
'user-output': CircleUser, 'user-output': CircleUser,
agent: Bot, agent: Bot, // fallback when no provider is resolved
distributor: Shuffle, distributor: Shuffle,
collector: Layers, collector: Layers,
orchestrator: Radio, orchestrator: Radio,
@@ -24,23 +25,30 @@ const kindColors: Record<PatternGraphNodeKind, { bg: string; border: string; tex
}; };
function GraphNodeContent({ data, selected }: { data: GraphNodeData; selected: boolean }) { function GraphNodeContent({ data, selected }: { data: GraphNodeData; selected: boolean }) {
const Icon = kindIcons[data.kind] ?? Bot;
const colors = kindColors[data.kind] ?? kindColors.agent; const colors = kindColors[data.kind] ?? kindColors.agent;
const isAgent = data.kind === 'agent'; const isAgent = data.kind === 'agent';
const renderIcon = () => {
if (isAgent && data.provider) {
return <ProviderIcon provider={data.provider} className="size-4 shrink-0" />;
}
const FallbackIcon = kindIcons[data.kind] ?? Bot;
return <FallbackIcon className={`size-4 shrink-0 ${colors.text}`} />;
};
return ( return (
<div <div
className={`flex min-w-[120px] items-center gap-2 rounded-xl border px-3 py-2 shadow-md transition ${ className={`flex min-w-[120px] items-center gap-2 rounded-xl border px-3 py-2 shadow-md transition ${
colors.bg colors.bg
} ${selected ? 'ring-2 ring-indigo-500/50' : ''} ${colors.border}`} } ${selected ? 'ring-2 ring-indigo-500/50' : ''} ${colors.border}`}
> >
<Icon className={`size-4 shrink-0 ${colors.text}`} /> {renderIcon()}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className={`truncate text-[12px] font-semibold ${colors.text}`}> <div className={`truncate text-[12px] font-semibold ${colors.text}`}>
{data.label} {data.label}
</div> </div>
{isAgent && typeof data.order === 'number' && ( {isAgent && data.modelLabel && (
<div className="text-[10px] text-zinc-500">#{data.order + 1}</div> <div className="truncate text-[10px] text-zinc-500">{data.modelLabel}</div>
)} )}
</div> </div>
{data.readOnly && ( {data.readOnly && (
@@ -3,6 +3,7 @@ import {
ReactFlow, ReactFlow,
Background, Background,
BackgroundVariant, BackgroundVariant,
MarkerType,
useNodesState, useNodesState,
useEdgesState, useEdgesState,
type Node, type Node,
@@ -15,6 +16,7 @@ import '@xyflow/react/dist/style.css';
import type { OrchestrationMode, 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 type { ModelDefinition } from '@shared/domain/models';
import { import {
addHandoffEdge, addHandoffEdge,
fromCanvasPositions, fromCanvasPositions,
@@ -30,6 +32,7 @@ import { graphNodeTypes } from './GraphNodes';
interface PatternGraphCanvasProps { interface PatternGraphCanvasProps {
pattern: PatternDefinition; pattern: PatternDefinition;
availableModels?: ReadonlyArray<ModelDefinition>;
onGraphChange: (graph: PatternGraph) => void; onGraphChange: (graph: PatternGraph) => void;
onNodeSelect: (nodeId: string | null) => void; onNodeSelect: (nodeId: string | null) => void;
selectedNodeId: string | null; selectedNodeId: string | null;
@@ -37,6 +40,7 @@ interface PatternGraphCanvasProps {
export function PatternGraphCanvas({ export function PatternGraphCanvas({
pattern, pattern,
availableModels,
onGraphChange, onGraphChange,
onNodeSelect, onNodeSelect,
selectedNodeId, selectedNodeId,
@@ -45,7 +49,7 @@ export function PatternGraphCanvas({
const draggingRef = useRef(false); const draggingRef = useRef(false);
const [nodes, setNodes, onNodesChange] = useNodesState( const [nodes, setNodes, onNodesChange] = useNodesState(
toCanvasNodes(graph, pattern.agents), toCanvasNodes(graph, pattern.agents, availableModels),
); );
const [edges, setEdges, onEdgesChange] = useEdgesState( const [edges, setEdges, onEdgesChange] = useEdgesState(
toCanvasEdges(graph, pattern.mode), toCanvasEdges(graph, pattern.mode),
@@ -53,9 +57,9 @@ export function PatternGraphCanvas({
// Sync canvas when pattern changes externally // Sync canvas when pattern changes externally
useEffect(() => { useEffect(() => {
setNodes(toCanvasNodes(graph, pattern.agents)); setNodes(toCanvasNodes(graph, pattern.agents, availableModels));
setEdges(toCanvasEdges(graph, pattern.mode)); setEdges(toCanvasEdges(graph, pattern.mode));
}, [graph, pattern.agents, pattern.mode, setNodes, setEdges]); }, [graph, pattern.agents, pattern.mode, availableModels, setNodes, setEdges]);
const handleNodesChange: OnNodesChange<Node<GraphNodeData>> = useCallback( const handleNodesChange: OnNodesChange<Node<GraphNodeData>> = useCallback(
(changes) => { (changes) => {
@@ -162,6 +166,7 @@ export function PatternGraphCanvas({
defaultEdgeOptions={{ defaultEdgeOptions={{
type: 'smoothstep', type: 'smoothstep',
style: { stroke: '#52525b', strokeWidth: 1.5 }, style: { stroke: '#52525b', strokeWidth: 1.5 },
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: '#52525b' },
}} }}
connectionLineStyle={{ stroke: '#6366f1', strokeWidth: 1.5 }} connectionLineStyle={{ stroke: '#6366f1', strokeWidth: 1.5 }}
deleteKeyCode="Delete" deleteKeyCode="Delete"
+45 -17
View File
@@ -1,4 +1,4 @@
import type { Node, Edge, Connection } from '@xyflow/react'; import { MarkerType, type Node, type Edge, type Connection } from '@xyflow/react';
import type { import type {
OrchestrationMode, OrchestrationMode,
@@ -10,6 +10,8 @@ import type {
PatternGraphNodeKind, PatternGraphNodeKind,
} from '@shared/domain/pattern'; } from '@shared/domain/pattern';
import { resolvePatternGraph } from '@shared/domain/pattern'; import { resolvePatternGraph } from '@shared/domain/pattern';
import type { ModelProvider } from '@shared/domain/models';
import { inferProvider, findModel, type ModelDefinition } from '@shared/domain/models';
/* ── Canvas node data ──────────────────────────────────────── */ /* ── Canvas node data ──────────────────────────────────────── */
@@ -19,6 +21,10 @@ export interface GraphNodeData extends Record<string, unknown> {
agentId?: string; agentId?: string;
order?: number; order?: number;
readOnly: boolean; readOnly: boolean;
/** AI provider inferred from the agent's model (agent nodes only). */
provider?: ModelProvider;
/** Short display name for the agent's model (agent nodes only). */
modelLabel?: string;
} }
/* ── View-model projection ─────────────────────────────────── */ /* ── View-model projection ─────────────────────────────────── */
@@ -57,22 +63,42 @@ function resolveNodeType(kind: PatternGraphNodeKind): string {
} }
} }
export function toCanvasNodes(graph: PatternGraph, agents: PatternAgentDefinition[]): Node<GraphNodeData>[] { export function toCanvasNodes(
return graph.nodes.map((node) => ({ graph: PatternGraph,
id: node.id, agents: PatternAgentDefinition[],
type: resolveNodeType(node.kind), models?: ReadonlyArray<ModelDefinition>,
position: { x: node.position.x, y: node.position.y }, ): Node<GraphNodeData>[] {
data: { return graph.nodes.map((node) => {
label: resolveNodeLabel(node, agents), let provider: ModelProvider | undefined;
kind: node.kind, let modelLabel: string | undefined;
agentId: node.agentId,
order: node.order, if (node.kind === 'agent' && node.agentId) {
readOnly: isSystemNode(node.kind), const agent = agents.find((a) => a.id === node.agentId);
}, if (agent?.model) {
draggable: true, provider = inferProvider(agent.model);
selectable: true, const modelDef = models ? findModel(agent.model, models) : undefined;
deletable: false, modelLabel = modelDef?.name ?? agent.model;
})); }
}
return {
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),
provider,
modelLabel,
},
draggable: true,
selectable: true,
deletable: false,
};
});
} }
/** Determines whether user-created edges can be deleted in this mode. */ /** Determines whether user-created edges can be deleted in this mode. */
@@ -97,6 +123,8 @@ export function toCanvasEdges(graph: PatternGraph, mode: OrchestrationMode): Edg
type: 'smoothstep', type: 'smoothstep',
animated: mode === 'handoff', animated: mode === 'handoff',
deletable: isEdgeDeletable(edge, mode, graph), deletable: isEdgeDeletable(edge, mode, graph),
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: '#52525b' },
style: { stroke: '#52525b', strokeWidth: 1.5 },
})); }));
} }
+42
View File
@@ -258,4 +258,46 @@ describe('edge deletion rules', () => {
expect(inputNode!.type).toBe('userInputNode'); expect(inputNode!.type).toBe('userInputNode');
expect(outputNode!.type).toBe('userOutputNode'); expect(outputNode!.type).toBe('userOutputNode');
}); });
test('edges have directional arrow markers', () => {
const pattern = findPattern('sequential');
const graph = resolvePatternGraph(pattern);
const edges = toCanvasEdges(graph, pattern.mode);
expect(edges.length).toBeGreaterThan(0);
for (const edge of edges) {
expect(edge.markerEnd).toBeDefined();
expect((edge.markerEnd as { type: string }).type).toBe('arrowclosed');
}
});
test('agent nodes include provider and model label when models catalog is provided', () => {
const { modelCatalog } = require('@shared/domain/models');
const pattern = findPattern('sequential');
const graph = resolvePatternGraph(pattern);
const nodes = toCanvasNodes(graph, pattern.agents, modelCatalog);
const agentNodes = nodes.filter((n) => n.data.kind === 'agent');
expect(agentNodes.length).toBeGreaterThan(0);
for (const node of agentNodes) {
expect(node.data.provider).toBeDefined();
expect(node.data.modelLabel).toBeDefined();
expect(node.data.modelLabel!.length).toBeGreaterThan(0);
}
});
test('agent nodes infer provider from model id without models catalog', () => {
const pattern = findPattern('sequential');
const graph = resolvePatternGraph(pattern);
const nodes = toCanvasNodes(graph, pattern.agents);
const agentNodes = nodes.filter((n) => n.data.kind === 'agent');
for (const node of agentNodes) {
// Provider should still be inferred from model id prefix
expect(node.data.provider).toBeDefined();
// Without catalog, modelLabel falls back to the raw model id
expect(node.data.modelLabel).toBeDefined();
}
});
}); });