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">
<PatternGraphCanvas
pattern={pattern}
availableModels={availableModels}
onGraphChange={emitGraphChange}
onNodeSelect={setSelectedNodeId}
selectedNodeId={selectedNodeId}
@@ -1,14 +1,15 @@
import { memo } from '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 { PatternGraphNodeKind } from '@shared/domain/pattern';
import { ProviderIcon } from '@renderer/components/ProviderIcons';
const kindIcons: Record<PatternGraphNodeKind, typeof CircleUser> = {
'user-input': CircleUser,
'user-output': CircleUser,
agent: Bot,
agent: Bot, // fallback when no provider is resolved
distributor: Shuffle,
collector: Layers,
orchestrator: Radio,
@@ -24,23 +25,30 @@ const kindColors: Record<PatternGraphNodeKind, { bg: string; border: string; tex
};
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';
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 (
<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}`} />
{renderIcon()}
<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>
{isAgent && data.modelLabel && (
<div className="truncate text-[10px] text-zinc-500">{data.modelLabel}</div>
)}
</div>
{data.readOnly && (
@@ -3,6 +3,7 @@ import {
ReactFlow,
Background,
BackgroundVariant,
MarkerType,
useNodesState,
useEdgesState,
type Node,
@@ -15,6 +16,7 @@ import '@xyflow/react/dist/style.css';
import type { OrchestrationMode, PatternDefinition, PatternGraph } from '@shared/domain/pattern';
import { resolvePatternGraph } from '@shared/domain/pattern';
import type { ModelDefinition } from '@shared/domain/models';
import {
addHandoffEdge,
fromCanvasPositions,
@@ -30,6 +32,7 @@ import { graphNodeTypes } from './GraphNodes';
interface PatternGraphCanvasProps {
pattern: PatternDefinition;
availableModels?: ReadonlyArray<ModelDefinition>;
onGraphChange: (graph: PatternGraph) => void;
onNodeSelect: (nodeId: string | null) => void;
selectedNodeId: string | null;
@@ -37,6 +40,7 @@ interface PatternGraphCanvasProps {
export function PatternGraphCanvas({
pattern,
availableModels,
onGraphChange,
onNodeSelect,
selectedNodeId,
@@ -45,7 +49,7 @@ export function PatternGraphCanvas({
const draggingRef = useRef(false);
const [nodes, setNodes, onNodesChange] = useNodesState(
toCanvasNodes(graph, pattern.agents),
toCanvasNodes(graph, pattern.agents, availableModels),
);
const [edges, setEdges, onEdgesChange] = useEdgesState(
toCanvasEdges(graph, pattern.mode),
@@ -53,9 +57,9 @@ export function PatternGraphCanvas({
// Sync canvas when pattern changes externally
useEffect(() => {
setNodes(toCanvasNodes(graph, pattern.agents));
setNodes(toCanvasNodes(graph, pattern.agents, availableModels));
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(
(changes) => {
@@ -162,6 +166,7 @@ export function PatternGraphCanvas({
defaultEdgeOptions={{
type: 'smoothstep',
style: { stroke: '#52525b', strokeWidth: 1.5 },
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: '#52525b' },
}}
connectionLineStyle={{ stroke: '#6366f1', strokeWidth: 1.5 }}
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 {
OrchestrationMode,
@@ -10,6 +10,8 @@ import type {
PatternGraphNodeKind,
} 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 ──────────────────────────────────────── */
@@ -19,6 +21,10 @@ export interface GraphNodeData extends Record<string, unknown> {
agentId?: string;
order?: number;
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 ─────────────────────────────────── */
@@ -57,22 +63,42 @@ function resolveNodeType(kind: PatternGraphNodeKind): string {
}
}
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 toCanvasNodes(
graph: PatternGraph,
agents: PatternAgentDefinition[],
models?: ReadonlyArray<ModelDefinition>,
): Node<GraphNodeData>[] {
return graph.nodes.map((node) => {
let provider: ModelProvider | undefined;
let modelLabel: string | undefined;
if (node.kind === 'agent' && node.agentId) {
const agent = agents.find((a) => a.id === node.agentId);
if (agent?.model) {
provider = inferProvider(agent.model);
const modelDef = models ? findModel(agent.model, models) : undefined;
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. */
@@ -97,6 +123,8 @@ export function toCanvasEdges(graph: PatternGraph, mode: OrchestrationMode): Edg
type: 'smoothstep',
animated: mode === 'handoff',
deletable: isEdgeDeletable(edge, mode, graph),
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: '#52525b' },
style: { stroke: '#52525b', strokeWidth: 1.5 },
}));
}