Agent Graph
A workflow DAG drawn in pure SVG from nodes and edges alone — layered auto-layout with crossing reduction, typed node shapes, status tints, flowing active wires, loop-back routing and four data states.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/agent-graph.jsonPrompt
Build a React + TypeScript + Tailwind "AgentGraph" component with zod and
lucide-react. Pure SVG — no graph library, no canvas, no physics simulation.
Contract
- A zod schema in a sibling contract file is the single source of truth and is
TOPOLOGICAL ONLY: { status, nodes: Node[], edges: Edge[] } where
Node = { id, label, type, status, detail? } and
Edge = { from, to, active?, label? }.
- `type` is "agent" | "tool" | "decision" | "io" and picks the node's SHAPE.
`status` is "idle" | "running" | "done" | "failed" | "skipped" and picks its
COLOUR. Two channels, two meanings: the diagram survives greyscale printing
and a colour-blind reader because shape never encodes state.
- "skipped" is not "idle": an idle node is still going to run, a skipped one
never will because the branch went the other way. Keep them separate.
- There are NO coordinates in the contract, ever. The moment a backend can ship
x/y you own a layout editor, which is a different product. Layout is computed
from the topology on every render, so the same payload always draws the same
picture — server, screenshot and hot reload agree.
- The envelope status — "loading" | "empty" | "error" | "ready" — is the
picture's own state, independent of any node's status: `status="error"` means
the workflow definition failed to load, while a node whose status is "failed"
is a step that broke inside a definition that loaded fine.
- Props: nodes; edges; status; direction ("right" | "down", default "right");
fit ("width" | "scroll", default "width"); nodeWidth (176); nodeHeight (58);
layerGap (60); nodeGap (16); labelLines (2); animateFlow (true);
highlightNeighbors (true); showLegend (true); showEdgeLabels (true);
selectedId?; defaultSelectedId?; onNodeSelect?(node); emptyState?;
errorMessage?; onRetry?; label? ("Agent workflow"); className plus the rest of
the div props, ref forwarded to the root.
- Every numeric prop is clamped (nodeWidth 80–480, nodeHeight 40–200, layerGap
24–240, nodeGap 8–160, labelLines 1–3) so a 0 or a NaN can never produce an
empty or infinite canvas.
- Export the layout function and the shape-path helper as well as the component:
a consumer who wants a legend, a minimap or a test asserting "these two nodes
land on the same rank" should not have to re-implement the ranking.
Behavior — layout is the component
- Normalise first. Duplicate node ids collapse to the first occurrence (two
boxes with one id make every edge naming it ambiguous). An edge whose endpoint
is not in `nodes`, or that points at itself, is dropped AND COUNTED, and the
count is stated above the diagram — a wire that vanishes silently is a picture
that lies about the pipeline. Duplicate from/to pairs merge into one wire, but
the merge keeps `active` if either copy had it.
- Rank with a Kahn drain plus longest path: layer(v) = 1 + max(layer of its
inputs), so every forward wire points at a strictly later layer. Only unsettled
nodes may be relaxed; pushing an already-placed node further right would drag
its whole subtree along mid-pass and turn a forward wire into a fake loop.
- CYCLES ARE EXPECTED, not rejected. Agent workflows loop by design
(critique → revise → critique). When the queue empties with nodes left over,
force the weakest remaining node (fewest unresolved inputs, input order breaks
ties) to be a root and resume the drain. The edge that closes the cycle is then
drawn as a return wire that bows clear of the boxes — leaving from the far edge
of the source, re-entering the far edge of the target — and the canvas grows by
the bow's apex so it can never be clipped.
- A wire that skips layers gets a BEND POINT in every layer it crosses, and each
bend competes for a slot exactly like a real box does. That is what keeps a
wire spanning four layers from being drawn underneath the boxes it flies over,
and it costs one reserved lane, which is the honest trade.
- Cut crossings with alternating barycenter sweeps (down using predecessors, up
using successors, three passes). Publish a layer's new indices immediately
after sorting it: a sweep that reads stale positions for the layer it has just
sorted is optimising against a picture that is no longer on screen. A cell with
no neighbours keeps its current index as its barycenter, so untethered boxes
stay put instead of drifting to the top on every pass.
- Assign coordinates last, with one axis for the rank and one for the slot;
`direction` only decides which is which, so every geometry calculation is
written once. Centre each layer across the widest layer — a two-box layer
facing a five-box layer should read as a fan, not as a left-aligned stub.
- Forward wires are chains of cubic Béziers whose control points sit on the flow
axis, so the joints at bend points are tangent-continuous and the whole run
reads as one stroke. Arrowheads are <marker> elements — TWO of them, one per
tone, because a marker's own `currentColor` resolves against <defs> and not
against the path referencing it.
- Labels never overflow and never clip silently: word-wrap to `labelLines` lines
against a character budget derived from the box width, hard-split a word longer
than one line, and end with an ellipsis. Whatever was cut stays in the
accessible name and in a <title> tooltip.
- Interaction, all of it optional: passing `onNodeSelect` turns the drawing into
a listbox. Hovering or keyboard-cursoring a box dims everything not wired to it
(the trace), which is how a reader answers "what feeds this?" on a busy graph
without a legend. Pointerleave is GUARDED (only clear the hover if it is still
yours) because leave on the old box lands before enter on the new one, and an
unguarded reset blinks the whole diagram between neighbours.
- The selection is controllable (`selectedId` + `onNodeSelect`) and falls back to
internal state. Both the selection and the keyboard cursor are validated
against the current node list on every render, so a graph that reloads with a
different node set can never leave a ring floating on a box that is gone.
Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
fill-card for the boxes, var(--chart-1) for running and for active wires,
var(--chart-2) for done, var(--destructive) for failed,
var(--muted-foreground) for idle, skipped and for quiet wires. No hard-coded
colours, no hex, no oklch anywhere.
- Type is a shape (rounded box / chamfered plate / hexagon / stadium), status is
a colour AND a glyph, and `skipped` additionally gets a dashed outline. Colour
is never the only carrier of anything.
- Active wires are dashed and their dash offset animates from the pattern length
to zero, i.e. flowing from source to target. The animation is SMIL inside the
path, not a CSS keyframe, because the component must ship as one file with no
stylesheet of its own; under prefers-reduced-motion the <animate> element is
simply not emitted — the dashes stay, the movement stops, nothing about the
meaning is lost.
- fit="width" gives the <svg> a viewBox, width:100% and a max-width of its
natural size: the whole graph is always complete and never scrolls, shrinking
its text as it shrinks. fit="scroll" pins natural size and puts the scroller
inside the card — past roughly five layers, fitting is buying space with
legibility and scrolling is the right answer.
- Accessibility has two modes. Read-only: the <svg> is role="img" with an
aria-label, and an sr-only ordered list next to it spells the topology out one
sentence per box ("Draft reply. agent, running. Fed by Confident?. Feeds Send
reply."). Interactive: the <svg> is a role="listbox" with tabIndex 0 and
aria-activedescendant, each box is a role="option" with that same sentence as
its name, arrow keys walk ranks and slots, Home/End jump to the ends and
Enter/Space select. The focus ring is drawn by the component (a foreground
stroke over a background halo) because an outline on an SVG child is not
reliably visible and `ring` alone does not clear 3:1 on a light card.
- cn() merges the consumer's className into the root, which also spreads the
remaining props and forwards its ref.
Customization levers
- Flow: `direction="down"` for a tall column of stages, `"right"` for a pipeline.
Everything else is unchanged — the geometry is written once against a rank axis
and a slot axis.
- Density: `nodeWidth` / `nodeHeight` / `layerGap` / `nodeGap` are the whole
layout budget. Narrow boxes shrink the character budget, so drop `labelLines`
to 1 for a compact map or raise it to 3 for verbose step names.
- Legibility: `fit` decides whether a wide graph shrinks or scrolls. Pick scroll
the moment the labels would go under about nine pixels.
- Noise: `showLegend`, `showEdgeLabels`, `highlightNeighbors` and `animateFlow`
each turn off independently — a graph embedded as a thumbnail wants all four
off, a debugging surface wants all four on.
- Colour: swap the four entries of the status tone map to move the diagram onto
your own chart ramp (var(--chart-3) for running, for instance); swap the glyph
map to change the status vocabulary. Both are plain records at the top of the
file, and neither knows anything about layout.
- Semantics: extend the type union with a new shape by adding one branch to the
shape-path helper — nothing else in the component reads `type`.
- Interaction: omit `onNodeSelect` and the whole listbox layer disappears,
leaving a static picture with a screen-reader description. Pass `selectedId` to
drive the ring from a router or a side panel.Concepts
- Topology in, picture out — the contract carries no coordinates, so nobody has to maintain a canvas alongside the workflow definition. A node an API adds tomorrow is placed by the same ranking that placed the rest, and because the layout is a pure function of the data, the server render, the screenshot and the hot reload are pixel-identical.
- A loop is data, not an error — agent workflows revise their own output, so the ranking step is built to run out of ready nodes, force the weakest remaining one to be a root and carry on. The edge that closes the cycle is drawn as a return wire bowing clear of the boxes, and the canvas grows by its apex so it can never be clipped.
- Bend points reserve a lane, then crossings are cut — a wire that skips layers drops a placeholder into every layer it crosses, and that placeholder competes for a slot exactly like a box: one lane of height, in exchange for the guarantee that no wire is drawn underneath the box it flies over. Alternating barycenter sweeps then reorder each layer against the average position of its neighbours, publishing new indices immediately so the next layer optimises against what is actually on screen.
- Shape says what, colour says how it is going — type picks the outline (agent, tool, decision, I/O) and status picks the tone and the glyph, so the diagram still parses in greyscale, in print and for a colour-blind reader.
skippedgets a dashed outline on top, because "will never run" and "has not run yet" must not look alike. - Trace by dimming — hovering or keyboard-cursoring a box fades everything it is not wired to, which answers "what feeds this?" on a crowded graph without a legend, a tooltip or a click. The clear-on-leave is guarded so moving between neighbours does not flash the whole picture back.
- Fit is a legibility budget — scaling a wide graph into a column is free until the labels drop under about nine pixels; after that
fit="scroll"keeps natural size and puts the scroller inside the card, which is the honest trade instead of a diagram nobody can read.
Agent Roster
A multi-agent fleet panel — per-agent state dot, model chip, current-task line and capacity bar, attention-first sorting, a real listbox keyboard path, and four data states.
Inline Completion
Ghost text for a textarea — a caret-aligned mirror paints the model's continuation in muted type, Tab accepts it, Cmd/Ctrl+Right takes one word, Esc dismisses.