{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "agent-graph",
  "title": "Agent Graph",
  "description": "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.",
  "dependencies": [
    "lucide-react",
    "zod"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/agent-graph.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  AlertCircle,\n  ArrowRightLeft,\n  Bot,\n  Circle,\n  CircleAlert,\n  CircleCheck,\n  CircleDashed,\n  CircleSlash,\n  RefreshCcw,\n  Split,\n  Workflow,\n  Wrench,\n} from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport type {\n  AgentGraphEdge,\n  AgentGraphNode,\n  AgentGraphNodeStatus,\n  AgentGraphNodeType,\n  AgentGraphStatus,\n} from \"./agent-graph.contract\"\n\n/* ------------------------------------------------------------------ tokens */\n\n/**\n * Status is the only thing that carries colour, and it carries it through\n * semantic tokens exclusively: swap the theme and the whole diagram follows,\n * dark mode included. `idle` and `skipped` deliberately share a tone — they are\n * told apart by their glyph and by the dashed outline `skipped` gets, never by\n * hue alone.\n */\nconst STATUS_TONE: Record<AgentGraphNodeStatus, string> = {\n  idle: \"var(--muted-foreground)\",\n  running: \"var(--chart-1)\",\n  done: \"var(--chart-2)\",\n  failed: \"var(--destructive)\",\n  skipped: \"var(--muted-foreground)\",\n}\n\nconst STATUS_WORD: Record<AgentGraphNodeStatus, string> = {\n  idle: \"not started\",\n  running: \"running\",\n  done: \"done\",\n  failed: \"failed\",\n  skipped: \"skipped\",\n}\n\ntype Glyph = typeof Circle\n\nconst STATUS_ICON: Record<AgentGraphNodeStatus, Glyph> = {\n  idle: Circle,\n  running: CircleDashed,\n  done: CircleCheck,\n  failed: CircleAlert,\n  skipped: CircleSlash,\n}\n\nconst TYPE_ICON: Record<AgentGraphNodeType, Glyph> = {\n  agent: Bot,\n  tool: Wrench,\n  decision: Split,\n  io: ArrowRightLeft,\n}\n\nconst TYPE_WORD: Record<AgentGraphNodeType, string> = {\n  agent: \"agent\",\n  tool: \"tool\",\n  decision: \"decision\",\n  io: \"input / output\",\n}\n\n/** The wire's own tone. Traffic reads as the accent; everything else is chrome. */\nconst EDGE_TONE = \"var(--muted-foreground)\"\nconst EDGE_TONE_ACTIVE = \"var(--chart-1)\"\n\n/* -------------------------------------------------------------- geometry */\n\nexport interface AgentGraphPoint {\n  x: number\n  y: number\n}\n\nconst PADDING = 18\nconst LABEL_SIZE = 12.5\nconst LABEL_LINE = 15\nconst DETAIL_SIZE = 10.5\nconst DETAIL_LINE = 13\nconst EDGE_LABEL_SIZE = 10\nconst TYPE_ICON_SIZE = 16\nconst STATUS_ICON_SIZE = 13\n/** Average advance of a UI sans at these sizes; good enough to budget characters, never to typeset. */\nconst CHAR_RATIO = 0.56\n/** Down-then-up barycenter sweeps. Three is where crossing counts stop improving on graphs this size. */\nconst CROSSING_PASSES = 3\n\n/** Two decimals is plenty for a diagram and keeps the path strings (and DOM diffs) small. */\nfunction r2(value: number): number {\n  return Math.round(value * 100) / 100\n}\n\nfunction clampNumber(value: number | undefined, min: number, max: number, fallback: number): number {\n  if (value === undefined || !Number.isFinite(value)) return fallback\n  return Math.min(max, Math.max(min, value))\n}\n\nfunction roundedRectPath(x: number, y: number, w: number, h: number, radius: number): string {\n  const r = Math.max(0, Math.min(radius, w / 2, h / 2))\n  return [\n    `M ${r2(x + r)} ${r2(y)}`,\n    `H ${r2(x + w - r)}`,\n    `A ${r2(r)} ${r2(r)} 0 0 1 ${r2(x + w)} ${r2(y + r)}`,\n    `V ${r2(y + h - r)}`,\n    `A ${r2(r)} ${r2(r)} 0 0 1 ${r2(x + w - r)} ${r2(y + h)}`,\n    `H ${r2(x + r)}`,\n    `A ${r2(r)} ${r2(r)} 0 0 1 ${r2(x)} ${r2(y + h - r)}`,\n    `V ${r2(y + r)}`,\n    `A ${r2(r)} ${r2(r)} 0 0 1 ${r2(x + r)} ${r2(y)}`,\n    \"Z\",\n  ].join(\" \")\n}\n\n/**\n * One shape per node type — a rounded card for an agent, a chamfered plate for a\n * tool, a hexagon for a decision, a stadium for an I/O boundary. Shape is the\n * type channel precisely because colour is already spoken for by status: a\n * greyscale print of this diagram still says which box is a branch.\n */\nexport function agentGraphShapePath(\n  type: AgentGraphNodeType,\n  x: number,\n  y: number,\n  w: number,\n  h: number,\n): string {\n  if (type === \"decision\") {\n    const notch = Math.min(h / 2, w / 4)\n    return [\n      `M ${r2(x + notch)} ${r2(y)}`,\n      `L ${r2(x + w - notch)} ${r2(y)}`,\n      `L ${r2(x + w)} ${r2(y + h / 2)}`,\n      `L ${r2(x + w - notch)} ${r2(y + h)}`,\n      `L ${r2(x + notch)} ${r2(y + h)}`,\n      `L ${r2(x)} ${r2(y + h / 2)}`,\n      \"Z\",\n    ].join(\" \")\n  }\n  if (type === \"tool\") {\n    const cut = Math.min(10, h / 4, w / 4)\n    return [\n      `M ${r2(x + cut)} ${r2(y)}`,\n      `L ${r2(x + w - cut)} ${r2(y)}`,\n      `L ${r2(x + w)} ${r2(y + cut)}`,\n      `L ${r2(x + w)} ${r2(y + h - cut)}`,\n      `L ${r2(x + w - cut)} ${r2(y + h)}`,\n      `L ${r2(x + cut)} ${r2(y + h)}`,\n      `L ${r2(x)} ${r2(y + h - cut)}`,\n      `L ${r2(x)} ${r2(y + cut)}`,\n      \"Z\",\n    ].join(\" \")\n  }\n  if (type === \"io\") return roundedRectPath(x, y, w, h, h / 2)\n  return roundedRectPath(x, y, w, h, Math.min(12, h / 3))\n}\n\n/**\n * Word wrap against a character budget, with the overflow ALWAYS marked.\n *\n * SVG text does not wrap and does not ellipsise, so a label longer than the box\n * would simply run out over its neighbours — the failure mode this exists to\n * prevent. A word longer than one line is hard-split rather than allowed to\n * bleed, and whatever did not fit is reported so the caller can keep the full\n * string in the accessible name and the tooltip.\n */\nexport function wrapGraphLabel(\n  text: string,\n  maxChars: number,\n  maxLines: number,\n): { lines: string[]; truncated: boolean } {\n  const budget = Math.max(3, Math.floor(maxChars))\n  const limit = Math.max(1, Math.floor(maxLines))\n  const words: string[] = []\n  for (const raw of text.trim().split(/\\s+/)) {\n    if (raw.length === 0) continue\n    let word = raw\n    while (word.length > budget) {\n      words.push(word.slice(0, budget))\n      word = word.slice(budget)\n    }\n    if (word.length > 0) words.push(word)\n  }\n  const lines: string[] = []\n  let current = \"\"\n  for (const word of words) {\n    if (current === \"\") current = word\n    else if (current.length + 1 + word.length <= budget) current = `${current} ${word}`\n    else {\n      lines.push(current)\n      current = word\n    }\n  }\n  if (current !== \"\") lines.push(current)\n  if (lines.length === 0) return { lines: [], truncated: false }\n  if (lines.length <= limit) return { lines, truncated: false }\n  const kept = lines.slice(0, limit)\n  const last = kept[limit - 1]\n  kept[limit - 1] = `${last.slice(0, Math.max(1, budget - 1)).trimEnd()}…`\n  return { lines: kept, truncated: true }\n}\n\n/* ---------------------------------------------------------------- layout */\n\nexport interface AgentGraphLayoutOptions {\n  /** Flow axis. `\"right\"` lays layers out left-to-right, `\"down\"` top-to-bottom. */\n  direction: \"right\" | \"down\"\n  nodeWidth: number\n  nodeHeight: number\n  /** Distance between two consecutive layers, along the flow axis. */\n  layerGap: number\n  /** Distance between two siblings inside one layer, across the flow axis. */\n  nodeGap: number\n}\n\nexport interface AgentGraphPlacedNode {\n  node: AgentGraphNode\n  /** Rank along the flow axis: 0 = has no inputs inside this graph. */\n  layer: number\n  /** Slot across the flow axis, after crossing reduction. */\n  slot: number\n  x: number\n  y: number\n  width: number\n  height: number\n}\n\nexport interface AgentGraphRoutedEdge {\n  key: string\n  from: string\n  to: string\n  label?: string\n  active: boolean\n  /**\n   * The target sits on the same layer or earlier — a loop back (critique →\n   * revise → critique). Not an error and not a warning: a feedback cycle is the\n   * whole point of half the agent workflows worth drawing. It is routed as a\n   * return wire around the boxes instead of through them.\n   */\n  loop: boolean\n  /** `d` of the wire, already routed around the boxes it passes. */\n  d: string\n  /** Where an edge label sits, if there is one. */\n  labelAt: AgentGraphPoint\n}\n\nexport interface AgentGraphLayout {\n  nodes: AgentGraphPlacedNode[]\n  edges: AgentGraphRoutedEdge[]\n  width: number\n  height: number\n  layerCount: number\n  /** Widest layer, counting the lanes reserved for wires that skip a layer. */\n  laneCount: number\n  /** Edges thrown away: an unknown endpoint, or a self-loop. Surfaced in the UI, never silent. */\n  droppedEdges: number\n  /** The input contains at least one cycle. Rendered, not rejected. */\n  hasCycle: boolean\n}\n\n/**\n * A slot-holder in a layer. `n<i>` cells are real boxes; `b<edge>_<layer>` cells\n * are the bend points a long wire drops in every layer it crosses, and they\n * compete for a slot exactly like a box does — which is the whole reason a wire\n * spanning four layers never ends up drawn underneath one.\n */\ninterface Cell {\n  key: string\n  layer: number\n  slot: number\n}\n\ninterface NormalisedEdge {\n  from: number\n  to: number\n  active: boolean\n  label?: string\n}\n\n/**\n * Sugiyama, kept to its three honest steps: rank the nodes, order each rank to\n * cut crossings, then assign coordinates. No physics, no random seeds, no\n * animation settling into place — the same graph produces the same picture on\n * the server, in a screenshot, and after a hot reload.\n */\nexport function layoutAgentGraph(\n  nodes: readonly AgentGraphNode[],\n  edges: readonly AgentGraphEdge[],\n  options: AgentGraphLayoutOptions,\n): AgentGraphLayout {\n  const { direction, nodeWidth, nodeHeight, layerGap, nodeGap } = options\n\n  /* 1. Nodes: first id wins. Two boxes with one id would make every edge naming\n        it ambiguous, and an ambiguous edge is worse than a missing one. */\n  const uniqueNodes: AgentGraphNode[] = []\n  const indexById = new Map<string, number>()\n  for (const node of nodes) {\n    if (indexById.has(node.id)) continue\n    indexById.set(node.id, uniqueNodes.length)\n    uniqueNodes.push(node)\n  }\n  const count = uniqueNodes.length\n\n  /* 2. Edges: drop the impossible ones, merge the duplicates. A pair listed\n        twice is one wire — but if either copy is `active`, the wire is. */\n  const normalised: NormalisedEdge[] = []\n  const pairIndex = new Map<string, number>()\n  let droppedEdges = 0\n  for (const edge of edges) {\n    const from = indexById.get(edge.from)\n    const to = indexById.get(edge.to)\n    if (from === undefined || to === undefined || from === to) {\n      droppedEdges += 1\n      continue\n    }\n    const pair = `${from}->${to}`\n    const existing = pairIndex.get(pair)\n    if (existing !== undefined) {\n      const kept = normalised[existing]\n      kept.active = kept.active || edge.active === true\n      if (kept.label === undefined) kept.label = edge.label\n      continue\n    }\n    pairIndex.set(pair, normalised.length)\n    normalised.push({ from, to, active: edge.active === true, label: edge.label })\n  }\n\n  if (count === 0) {\n    return {\n      nodes: [],\n      edges: [],\n      width: 0,\n      height: 0,\n      layerCount: 0,\n      laneCount: 0,\n      droppedEdges,\n      hasCycle: false,\n    }\n  }\n\n  /* 3. Layering: longest path over a Kahn drain, so every forward edge points at\n        a strictly later layer. Cycles are expected here, not exceptional — when\n        the queue empties early the weakest remaining node (fewest unresolved\n        inputs, input order breaking ties) is forced to be a root and the drain\n        resumes. That turns \"the algorithm hangs on a loop\" into \"the loop is\n        drawn as a return wire\". */\n  const outAdjacency: number[][] = Array.from({ length: count }, () => [])\n  const remaining = new Array<number>(count).fill(0)\n  for (const edge of normalised) {\n    outAdjacency[edge.from].push(edge.to)\n    remaining[edge.to] += 1\n  }\n  const layerOf = new Array<number>(count).fill(0)\n  const settled = new Array<boolean>(count).fill(false)\n  const queue: number[] = []\n  let settledCount = 0\n  let hasCycle = false\n\n  for (let i = 0; i < count; i += 1) if (remaining[i] === 0) queue.push(i)\n\n  const drain = () => {\n    while (queue.length > 0) {\n      const node = queue.shift() as number\n      if (settled[node]) continue\n      settled[node] = true\n      settledCount += 1\n      for (const next of outAdjacency[node]) {\n        // Only an unsettled node may be pushed further right: relaxing one that\n        // is already placed would drag a whole subtree backwards mid-pass and\n        // turn a forward edge into a spurious loop.\n        if (!settled[next] && layerOf[next] < layerOf[node] + 1) layerOf[next] = layerOf[node] + 1\n        remaining[next] -= 1\n        if (remaining[next] <= 0 && !settled[next]) queue.push(next)\n      }\n    }\n  }\n\n  drain()\n  while (settledCount < count) {\n    hasCycle = true\n    let pick = -1\n    for (let i = 0; i < count; i += 1) {\n      if (settled[i]) continue\n      if (pick === -1 || remaining[i] < remaining[pick]) pick = i\n    }\n    remaining[pick] = 0\n    queue.push(pick)\n    drain()\n  }\n\n  let layerCount = 0\n  for (let i = 0; i < count; i += 1) layerCount = Math.max(layerCount, layerOf[i] + 1)\n\n  /* 4. Bend points. A wire that skips layers gets a placeholder cell in each\n        layer it crosses; that cell competes for a slot like a real node, which\n        is what keeps long wires out from under the boxes they fly over. */\n  const layers: Cell[][] = Array.from({ length: layerCount }, () => [])\n  const cellByKey = new Map<string, Cell>()\n  const addCell = (cell: Cell) => {\n    layers[cell.layer].push(cell)\n    cellByKey.set(cell.key, cell)\n  }\n  for (let i = 0; i < count; i += 1) {\n    addCell({ key: `n${i}`, layer: layerOf[i], slot: 0 })\n  }\n  const chains: string[][] = []\n  for (let e = 0; e < normalised.length; e += 1) {\n    const edge = normalised[e]\n    const start = layerOf[edge.from]\n    const end = layerOf[edge.to]\n    if (end <= start) {\n      chains.push([])\n      continue\n    }\n    const chain = [`n${edge.from}`]\n    for (let l = start + 1; l < end; l += 1) {\n      const key = `b${e}_${l}`\n      addCell({ key, layer: l, slot: 0 })\n      chain.push(key)\n    }\n    chain.push(`n${edge.to}`)\n    chains.push(chain)\n  }\n\n  /* 5. Crossing reduction: alternating barycenter sweeps. A cell with no\n        neighbours in the reference layer keeps its current index as its\n        barycenter, so untethered boxes stay put instead of drifting to the top\n        on every pass. */\n  const positions = new Map<string, number>()\n  const reindex = () => {\n    for (const cells of layers) {\n      for (let i = 0; i < cells.length; i += 1) {\n        cells[i].slot = i\n        positions.set(cells[i].key, i)\n      }\n    }\n  }\n  const successors = new Map<string, string[]>()\n  const predecessors = new Map<string, string[]>()\n  const link = (a: string, b: string) => {\n    const outs = successors.get(a)\n    if (outs) outs.push(b)\n    else successors.set(a, [b])\n    const ins = predecessors.get(b)\n    if (ins) ins.push(a)\n    else predecessors.set(b, [a])\n  }\n  for (const chain of chains) {\n    for (let i = 1; i < chain.length; i += 1) link(chain[i - 1], chain[i])\n  }\n  // One layer at a time, and its new positions are published immediately: a\n  // sweep that reads stale indices for the layer it just sorted is a sweep that\n  // optimises against a picture no longer on screen.\n  const sortLayer = (index: number, reference: Map<string, string[]>) => {\n    const decorated = layers[index].map((cell, order) => {\n      const neighbours = reference.get(cell.key)\n      let barycenter = order\n      if (neighbours !== undefined && neighbours.length > 0) {\n        let sum = 0\n        for (const key of neighbours) sum += positions.get(key) ?? 0\n        barycenter = sum / neighbours.length\n      }\n      return { cell, order, barycenter }\n    })\n    decorated.sort((a, b) => a.barycenter - b.barycenter || a.order - b.order)\n    const sorted = decorated.map(entry => entry.cell)\n    layers[index] = sorted\n    for (let i = 0; i < sorted.length; i += 1) {\n      sorted[i].slot = i\n      positions.set(sorted[i].key, i)\n    }\n  }\n\n  reindex()\n  for (let pass = 0; pass < CROSSING_PASSES; pass += 1) {\n    for (let l = 1; l < layerCount; l += 1) sortLayer(l, predecessors)\n    for (let l = layerCount - 2; l >= 0; l -= 1) sortLayer(l, successors)\n  }\n\n  /* 6. Coordinates. One axis is the rank, the other the slot; `direction` only\n        decides which is which, so every downstream calculation is written once. */\n  const alongSize = direction === \"right\" ? nodeWidth : nodeHeight\n  const crossSize = direction === \"right\" ? nodeHeight : nodeWidth\n  const alongStep = alongSize + layerGap\n  const crossStep = crossSize + nodeGap\n  let laneCount = 0\n  for (const cells of layers) laneCount = Math.max(laneCount, cells.length)\n\n  const centreOf = (cell: Cell): AgentGraphPoint => {\n    const lane = layers[cell.layer].length\n    // Each layer is centred across the widest one; a two-box layer facing a\n    // five-box layer reads as a fan, not as a left-aligned stub.\n    const offset = ((laneCount - lane) * crossStep) / 2\n    const along = PADDING + cell.layer * alongStep + alongSize / 2\n    const cross = PADDING + offset + cell.slot * crossStep + crossSize / 2\n    return direction === \"right\" ? { x: along, y: cross } : { x: cross, y: along }\n  }\n\n  const placed: AgentGraphPlacedNode[] = uniqueNodes.map((node, i) => {\n    const cell = cellByKey.get(`n${i}`) as Cell\n    const centre = centreOf(cell)\n    return {\n      node,\n      layer: cell.layer,\n      slot: cell.slot,\n      x: centre.x - nodeWidth / 2,\n      y: centre.y - nodeHeight / 2,\n      width: nodeWidth,\n      height: nodeHeight,\n    }\n  })\n\n  const exitPoint = (index: number): AgentGraphPoint => {\n    const box = placed[index]\n    return direction === \"right\"\n      ? { x: box.x + box.width, y: box.y + box.height / 2 }\n      : { x: box.x + box.width / 2, y: box.y + box.height }\n  }\n  const entryPoint = (index: number): AgentGraphPoint => {\n    const box = placed[index]\n    return direction === \"right\"\n      ? { x: box.x, y: box.y + box.height / 2 }\n      : { x: box.x + box.width / 2, y: box.y }\n  }\n\n  /* 7. Wires. Forward wires are a chain of cubics whose control points lie on\n        the flow axis, so the joints at the bend points are tangent-continuous\n        and the whole run reads as one stroke. */\n  const flowPath = (points: AgentGraphPoint[]): string => {\n    let d = `M ${r2(points[0].x)} ${r2(points[0].y)}`\n    for (let i = 1; i < points.length; i += 1) {\n      const a = points[i - 1]\n      const b = points[i]\n      if (direction === \"right\") {\n        const mid = (a.x + b.x) / 2\n        d += ` C ${r2(mid)} ${r2(a.y)} ${r2(mid)} ${r2(b.y)} ${r2(b.x)} ${r2(b.y)}`\n      } else {\n        const mid = (a.y + b.y) / 2\n        d += ` C ${r2(a.x)} ${r2(mid)} ${r2(b.x)} ${r2(mid)} ${r2(b.x)} ${r2(b.y)}`\n      }\n    }\n    return d\n  }\n\n  const loopBow = crossStep * 0.7 + 22\n  let loopRoom = 0\n\n  const routed: AgentGraphRoutedEdge[] = normalised.map((edge, index) => {\n    const chain = chains[index]\n    // Keyed on the deduplicated indices rather than on the two ids: pairs are\n    // already unique here, and no id can forge a collision through a separator.\n    const key = `e${edge.from}-${edge.to}`\n    if (chain.length === 0) {\n      // A return wire leaves and re-enters on the far side of the boxes, bowing\n      // clear of the row it belongs to. Nothing else in the picture uses that\n      // band, which is what makes a loop legible instead of a scribble.\n      const source = placed[edge.from]\n      const target = placed[edge.to]\n      const start =\n        direction === \"right\"\n          ? { x: source.x + source.width / 2, y: source.y + source.height }\n          : { x: source.x + source.width, y: source.y + source.height / 2 }\n      const end =\n        direction === \"right\"\n          ? { x: target.x + target.width / 2, y: target.y + target.height }\n          : { x: target.x + target.width, y: target.y + target.height / 2 }\n      const d =\n        direction === \"right\"\n          ? `M ${r2(start.x)} ${r2(start.y)} C ${r2(start.x)} ${r2(start.y + loopBow)} ${r2(end.x)} ${r2(end.y + loopBow)} ${r2(end.x)} ${r2(end.y)}`\n          : `M ${r2(start.x)} ${r2(start.y)} C ${r2(start.x + loopBow)} ${r2(start.y)} ${r2(end.x + loopBow)} ${r2(end.y)} ${r2(end.x)} ${r2(end.y)}`\n      const apex = 0.75 * loopBow\n      loopRoom = Math.max(loopRoom, apex + 10)\n      return {\n        key,\n        from: uniqueNodes[edge.from].id,\n        to: uniqueNodes[edge.to].id,\n        label: edge.label,\n        active: edge.active,\n        loop: true,\n        d,\n        labelAt:\n          direction === \"right\"\n            ? { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 + apex }\n            : { x: (start.x + end.x) / 2 + apex, y: (start.y + end.y) / 2 },\n      }\n    }\n\n    const points: AgentGraphPoint[] = [exitPoint(edge.from)]\n    for (let i = 1; i < chain.length - 1; i += 1) {\n      points.push(centreOf(cellByKey.get(chain[i]) as Cell))\n    }\n    points.push(entryPoint(edge.to))\n    const middle = Math.max(1, Math.floor(points.length / 2))\n    const a = points[middle - 1]\n    const b = points[middle]\n    return {\n      key,\n      from: uniqueNodes[edge.from].id,\n      to: uniqueNodes[edge.to].id,\n      label: edge.label,\n      active: edge.active,\n      loop: false,\n      d: flowPath(points),\n      labelAt: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },\n    }\n  })\n\n  const alongExtent = layerCount * alongStep - layerGap\n  const crossExtent = laneCount * crossStep - nodeGap + loopRoom\n  return {\n    nodes: placed,\n    edges: routed,\n    width: PADDING * 2 + (direction === \"right\" ? alongExtent : crossExtent),\n    height: PADDING * 2 + (direction === \"right\" ? crossExtent : alongExtent),\n    layerCount,\n    laneCount,\n    droppedEdges,\n    hasCycle,\n  }\n}\n\n/* --------------------------------------------------------- reduced motion */\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n  query.addEventListener(\"change\", callback)\n  return () => query.removeEventListener(\"change\", callback)\n}\n\nfunction useReducedMotion(): boolean {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  )\n}\n\n/* ------------------------------------------------------------------ prose */\n\nfunction joinNames(names: string[]): string {\n  if (names.length === 0) return \"\"\n  if (names.length === 1) return names[0]\n  return `${names.slice(0, -1).join(\", \")} and ${names[names.length - 1]}`\n}\n\n/**\n * The whole node as one sentence — the accessible name of the option, and the\n * screen-reader body of the read-only diagram. Topology included: without \"feeds\n * X and Y\" a screen-reader user gets a bag of boxes and no graph at all.\n */\nfunction describeNode(node: AgentGraphNode, incoming: string[], outgoing: string[]): string {\n  const parts = [`${node.label}. ${TYPE_WORD[node.type]}, ${STATUS_WORD[node.status]}.`]\n  if (node.detail !== undefined && node.detail.trim() !== \"\") parts.push(`${node.detail}.`)\n  parts.push(incoming.length === 0 ? \"No inputs.\" : `Fed by ${joinNames(incoming)}.`)\n  parts.push(outgoing.length === 0 ? \"No outputs.\" : `Feeds ${joinNames(outgoing)}.`)\n  return parts.join(\" \")\n}\n\n/* -------------------------------------------------------------- component */\n\nexport interface AgentGraphProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** The boxes. Duplicate ids collapse to the first occurrence. */\n  nodes: AgentGraphNode[]\n  /** The wires. Endpoints that name an unknown node are dropped and counted. */\n  edges: AgentGraphEdge[]\n  /** The picture's own state — independent of any node's status. */\n  status: AgentGraphStatus\n  /** Flow axis: `\"right\"` (default) for a pipeline, `\"down\"` for a tall column of stages. */\n  direction?: \"right\" | \"down\"\n  /**\n   * `\"width\"` (default) scales the whole diagram down to the container through\n   * the viewBox — always complete, never scrolled, text shrinking with it.\n   * `\"scroll\"` pins it at natural size and puts a horizontal scroller under it:\n   * the right answer for a 12-layer pipeline, where fitting would make the\n   * labels unreadable.\n   */\n  fit?: \"width\" | \"scroll\"\n  /** Box width in diagram units. Clamped to 80–480. */\n  nodeWidth?: number\n  /** Box height in diagram units. Clamped to 40–200. */\n  nodeHeight?: number\n  /** Gap between layers, along the flow. Clamped to 24–240. */\n  layerGap?: number\n  /** Gap between siblings inside a layer. Clamped to 8–160. */\n  nodeGap?: number\n  /** Label lines before an ellipsis. Clamped to 1–3. */\n  labelLines?: number\n  /** Draw the dash flow on `active` wires. Forced off under prefers-reduced-motion. */\n  animateFlow?: boolean\n  /** Hovering or keyboard-focusing a box dims everything that is not wired to it. */\n  highlightNeighbors?: boolean\n  /** The shape / status key under the diagram. */\n  showLegend?: boolean\n  /** Draw `edge.label` on the wire. */\n  showEdgeLabels?: boolean\n  /** Controlled selection. Pass `null` for \"nothing selected\"; omit it entirely to let the component own it. */\n  selectedId?: string | null\n  /** Initial selection when uncontrolled. */\n  defaultSelectedId?: string | null\n  /** Supplying it makes the boxes interactive: clickable, tabbable, arrow-navigable. Omit it and the diagram is a picture. */\n  onNodeSelect?: (node: AgentGraphNode) => void\n  /** Replaces the default `status=\"empty\"` body. */\n  emptyState?: React.ReactNode\n  /** Message shown in the `status=\"error\"` branch. */\n  errorMessage?: string\n  /** Renders \"Try again\" in the error branch; omit it and there is no button. */\n  onRetry?: () => void\n  /** Accessible name of the diagram. */\n  label?: string\n}\n\n/**\n * A workflow DAG drawn from its topology alone: nodes and edges in, a layered\n * picture out. No graph library, no physics, no stored coordinates — the layout\n * is recomputed from the data, so the same payload always draws the same\n * diagram, and a node added by an API is placed without anyone editing a canvas.\n */\nexport const AgentGraph = React.forwardRef<HTMLDivElement, AgentGraphProps>(\n  (\n    {\n      nodes,\n      edges,\n      status,\n      direction = \"right\",\n      fit = \"width\",\n      nodeWidth,\n      nodeHeight,\n      layerGap,\n      nodeGap,\n      labelLines,\n      animateFlow = true,\n      highlightNeighbors = true,\n      showLegend = true,\n      showEdgeLabels = true,\n      selectedId,\n      defaultSelectedId,\n      onNodeSelect,\n      emptyState,\n      errorMessage = \"This workflow couldn't be loaded.\",\n      onRetry,\n      label = \"Agent workflow\",\n      className,\n      ...props\n    },\n    ref,\n  ) => {\n    const rawId = React.useId()\n    // useId emits characters that are legal in an id but awkward in a fragment\n    // reference; stripping them keeps `url(#…)` and aria-activedescendant simple.\n    const uid = rawId.replace(/[^a-zA-Z0-9_-]/g, \"\")\n    const reduced = useReducedMotion()\n\n    // Defaults sized so that a five-layer graph still fits a normal content\n    // column at close to 1:1 — past that point `fit=\"width\"` is shrinking text\n    // faster than it is saving space, which is what `fit=\"scroll\"` is for.\n    const boxWidth = clampNumber(nodeWidth, 80, 480, 176)\n    const boxHeight = clampNumber(nodeHeight, 40, 200, 58)\n    const gapAlong = clampNumber(layerGap, 24, 240, 60)\n    const gapAcross = clampNumber(nodeGap, 8, 160, 16)\n    const lines = Math.round(clampNumber(labelLines, 1, 3, 2))\n\n    const layout = React.useMemo(\n      () =>\n        layoutAgentGraph(nodes, edges, {\n          direction,\n          nodeWidth: boxWidth,\n          nodeHeight: boxHeight,\n          layerGap: gapAlong,\n          nodeGap: gapAcross,\n        }),\n      [nodes, edges, direction, boxWidth, boxHeight, gapAlong, gapAcross],\n    )\n\n    const wiring = React.useMemo(() => {\n      const labelById = new Map<string, string>()\n      for (const placed of layout.nodes) labelById.set(placed.node.id, placed.node.label)\n      const incoming = new Map<string, string[]>()\n      const outgoing = new Map<string, string[]>()\n      for (const edge of layout.edges) {\n        const into = incoming.get(edge.to)\n        const name = labelById.get(edge.from) ?? edge.from\n        if (into) into.push(name)\n        else incoming.set(edge.to, [name])\n        const out = outgoing.get(edge.from)\n        const target = labelById.get(edge.to) ?? edge.to\n        if (out) out.push(target)\n        else outgoing.set(edge.from, [target])\n      }\n      return { incoming, outgoing }\n    }, [layout])\n\n    const interactive = onNodeSelect !== undefined\n    const [hovered, setHovered] = React.useState<string | null>(null)\n    const [cursorId, setCursorId] = React.useState<string | null>(null)\n    const [focused, setFocused] = React.useState(false)\n    const [uncontrolled, setUncontrolled] = React.useState<string | null>(defaultSelectedId ?? null)\n\n    const selected = selectedId === undefined ? uncontrolled : selectedId\n    // Derived, never stored: a graph that reloads with a different node set must\n    // not leave a cursor (or a selection ring) pointing at a box that is gone.\n    const exists = (id: string | null): string | null =>\n      id !== null && layout.nodes.some(placed => placed.node.id === id) ? id : null\n    const cursor = exists(cursorId)\n    const activeSelection = exists(selected)\n    const traced = highlightNeighbors ? (hovered ?? (focused ? cursor : null)) : null\n\n    const related = React.useMemo(() => {\n      if (traced === null) return null\n      const nodeIds = new Set<string>([traced])\n      const edgeKeys = new Set<string>()\n      for (const edge of layout.edges) {\n        if (edge.from !== traced && edge.to !== traced) continue\n        edgeKeys.add(edge.key)\n        nodeIds.add(edge.from)\n        nodeIds.add(edge.to)\n      }\n      return { nodeIds, edgeKeys }\n    }, [traced, layout])\n\n    const selectNode = (node: AgentGraphNode) => {\n      setCursorId(node.id)\n      if (selectedId === undefined) setUncontrolled(node.id)\n      onNodeSelect?.(node)\n    }\n\n    const crossCentre = (placed: AgentGraphPlacedNode) =>\n      direction === \"right\" ? placed.y + placed.height / 2 : placed.x + placed.width / 2\n\n    const stepLayer = (current: AgentGraphPlacedNode, delta: number): string | null => {\n      const target = crossCentre(current)\n      for (let l = current.layer + delta; l >= 0 && l < layout.layerCount; l += delta) {\n        const candidates = layout.nodes.filter(placed => placed.layer === l)\n        if (candidates.length === 0) continue\n        let best = candidates[0]\n        for (const candidate of candidates) {\n          if (Math.abs(crossCentre(candidate) - target) < Math.abs(crossCentre(best) - target)) best = candidate\n        }\n        return best.node.id\n      }\n      return null\n    }\n\n    const stepSlot = (current: AgentGraphPlacedNode, delta: number): string | null => {\n      const siblings = layout.nodes\n        .filter(placed => placed.layer === current.layer)\n        .sort((a, b) => a.slot - b.slot)\n      const at = siblings.findIndex(placed => placed.node.id === current.node.id)\n      const next = siblings[at + delta]\n      return next === undefined ? null : next.node.id\n    }\n\n    /**\n     * Home / End go to the ends of the FLOW, not to the ends of the input array:\n     * \"first\" means the topmost box of the first rank, which is where a reader\n     * expects the beginning of the pipeline to be.\n     */\n    const endNode = (last: boolean): string => {\n      let best = layout.nodes[0]\n      for (const placed of layout.nodes) {\n        const better = last\n          ? placed.layer > best.layer || (placed.layer === best.layer && placed.slot > best.slot)\n          : placed.layer < best.layer || (placed.layer === best.layer && placed.slot < best.slot)\n        if (better) best = placed\n      }\n      return best.node.id\n    }\n\n    const handleKeyDown = (event: React.KeyboardEvent<SVGSVGElement>) => {\n      if (!interactive || layout.nodes.length === 0) return\n      const { key } = event\n      const forward = direction === \"right\" ? \"ArrowRight\" : \"ArrowDown\"\n      const backward = direction === \"right\" ? \"ArrowLeft\" : \"ArrowUp\"\n      const nextSibling = direction === \"right\" ? \"ArrowDown\" : \"ArrowRight\"\n      const prevSibling = direction === \"right\" ? \"ArrowUp\" : \"ArrowLeft\"\n      const navigation = [forward, backward, nextSibling, prevSibling, \"Home\", \"End\"]\n      if (key !== \"Enter\" && key !== \" \" && !navigation.includes(key)) return\n      event.preventDefault()\n\n      const current = layout.nodes.find(placed => placed.node.id === cursor)\n      if (current === undefined) {\n        // First key inside the graph parks the cursor on the entry node rather\n        // than moving an invisible one — otherwise the first arrow appears dead.\n        setCursorId(endNode(false))\n        return\n      }\n      if (key === \"Enter\" || key === \" \") {\n        selectNode(current.node)\n        return\n      }\n      let next: string | null = null\n      if (key === forward) next = stepLayer(current, 1)\n      else if (key === backward) next = stepLayer(current, -1)\n      else if (key === nextSibling) next = stepSlot(current, 1)\n      else if (key === prevSibling) next = stepSlot(current, -1)\n      else if (key === \"Home\") next = endNode(false)\n      else if (key === \"End\") next = endNode(true)\n      if (next !== null) setCursorId(next)\n    }\n\n    const shell = (children: React.ReactNode) => (\n      <div\n        className={cn(\"flex w-full min-w-0 flex-col rounded-lg border bg-card text-sm\", className)}\n        data-status={status}\n        ref={ref}\n        {...props}\n      >\n        {children}\n      </div>\n    )\n\n    /* ------------------------------------------------------------ envelopes */\n\n    if (status === \"loading\") {\n      return shell(\n        <div aria-busy=\"true\" className=\"flex flex-col gap-3 p-4\">\n          <span className=\"sr-only\" role=\"status\">\n            Loading workflow\n          </span>\n          <div aria-hidden=\"true\" className=\"flex flex-wrap items-center gap-2\">\n            {[0, 1, 2, 3].map(index => (\n              <React.Fragment key={index}>\n                <div\n                  className=\"h-14 flex-1 animate-pulse rounded-lg bg-muted motion-reduce:animate-none\"\n                  style={{ minWidth: \"7rem\" }}\n                />\n                {index < 3 && <div className=\"h-px w-6 shrink-0 bg-border\" />}\n              </React.Fragment>\n            ))}\n          </div>\n          <div aria-hidden=\"true\" className=\"flex flex-wrap items-center gap-2 pl-10\">\n            <div className=\"h-14 flex-1 animate-pulse rounded-lg bg-muted motion-reduce:animate-none\" style={{ minWidth: \"7rem\" }} />\n            <div className=\"h-px w-6 shrink-0 bg-border\" />\n            <div className=\"h-14 flex-1 animate-pulse rounded-lg bg-muted motion-reduce:animate-none\" style={{ minWidth: \"7rem\" }} />\n          </div>\n        </div>,\n      )\n    }\n\n    if (status === \"error\") {\n      return shell(\n        <div className=\"flex flex-col items-center gap-3 px-4 py-10 text-center\" role=\"alert\">\n          <AlertCircle aria-hidden=\"true\" className=\"size-6 text-destructive\" />\n          <p className=\"min-w-0 whitespace-pre-wrap wrap-anywhere text-sm text-destructive\">{errorMessage}</p>\n          {onRetry && (\n            <button\n              className=\"inline-flex cursor-pointer items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none\"\n              onClick={onRetry}\n              type=\"button\"\n            >\n              <RefreshCcw aria-hidden=\"true\" className=\"size-3.5\" />\n              Try again\n            </button>\n          )}\n        </div>,\n      )\n    }\n\n    // `ready` with nothing to draw is a data-layer slip, not a third design.\n    if (status === \"empty\" || layout.nodes.length === 0) {\n      return shell(\n        emptyState ?? (\n          <div className=\"flex flex-col items-center gap-2 px-4 py-10 text-center\">\n            <Workflow aria-hidden=\"true\" className=\"size-6 text-muted-foreground\" />\n            <p className=\"text-sm text-muted-foreground\">No steps are wired up in this workflow yet.</p>\n          </div>\n        ),\n      )\n    }\n\n    /* ---------------------------------------------------------------- ready */\n\n    const inset = Math.round(Math.max(8, Math.min(14, boxHeight * 0.22)))\n    const textLeft = inset + TYPE_ICON_SIZE + 8\n    const textWidth = boxWidth - textLeft - inset - STATUS_ICON_SIZE - 8\n    const labelChars = Math.floor(textWidth / (LABEL_SIZE * CHAR_RATIO))\n    const detailChars = Math.floor(textWidth / (DETAIL_SIZE * CHAR_RATIO))\n    const flowing = animateFlow && !reduced\n\n    const typesPresent = Array.from(new Set(layout.nodes.map(placed => placed.node.type)))\n    const statusesPresent = Array.from(new Set(layout.nodes.map(placed => placed.node.status)))\n    const optionId = (id: string) => `${uid}-${encodeURIComponent(id).replace(/%/g, \"_\")}`\n    const descriptionId = `${uid}-desc`\n\n    return shell(\n      <>\n        {layout.droppedEdges > 0 && (\n          // Data that could not be drawn is stated, never swallowed: a wire that\n          // silently vanishes is a diagram that lies about the pipeline.\n          <p className=\"border-b px-3 py-2 text-[11px] text-muted-foreground\" role=\"status\">\n            {layout.droppedEdges} connection{layout.droppedEdges === 1 ? \"\" : \"s\"} ignored — an endpoint is missing\n            from this graph, or points at itself.\n          </p>\n        )}\n\n        <div className={cn(\"min-w-0 p-2\", fit === \"scroll\" && \"overflow-x-auto\")}>\n          <svg\n            aria-activedescendant={interactive && focused && cursor !== null ? optionId(cursor) : undefined}\n            aria-describedby={interactive ? undefined : descriptionId}\n            aria-label={label}\n            className={cn(\"block outline-none\", fit === \"width\" && \"mx-auto w-full\")}\n            height={fit === \"scroll\" ? layout.height : undefined}\n            onBlur={event => {\n              if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(false)\n            }}\n            onFocus={() => setFocused(true)}\n            onKeyDown={handleKeyDown}\n            onPointerLeave={() => setHovered(null)}\n            role={interactive ? \"listbox\" : \"img\"}\n            style={fit === \"width\" ? { maxWidth: `${layout.width}px`, height: \"auto\" } : undefined}\n            tabIndex={interactive ? 0 : undefined}\n            viewBox={`0 0 ${r2(layout.width)} ${r2(layout.height)}`}\n            width={fit === \"scroll\" ? layout.width : undefined}\n          >\n            <defs>\n              {/* Two markers, not one with `context-stroke`: the arrow has to\n                  match its wire's tone, and a marker's own `currentColor`\n                  resolves against <defs>, not against the path using it. */}\n              <marker\n                id={`${uid}-tip`}\n                markerHeight={7}\n                markerUnits=\"userSpaceOnUse\"\n                markerWidth={9}\n                orient=\"auto\"\n                refX={8.5}\n                refY={3.5}\n                viewBox=\"0 0 9 7\"\n              >\n                <path d=\"M 0 0 L 9 3.5 L 0 7 Z\" style={{ fill: EDGE_TONE }} />\n              </marker>\n              <marker\n                id={`${uid}-tip-active`}\n                markerHeight={7}\n                markerUnits=\"userSpaceOnUse\"\n                markerWidth={9}\n                orient=\"auto\"\n                refX={8.5}\n                refY={3.5}\n                viewBox=\"0 0 9 7\"\n              >\n                <path d=\"M 0 0 L 9 3.5 L 0 7 Z\" style={{ fill: EDGE_TONE_ACTIVE }} />\n              </marker>\n            </defs>\n\n            {/* Wires are decorative in the accessibility tree — the topology they\n                describe is spelled out in each box's name instead. */}\n            <g aria-hidden=\"true\">\n              {layout.edges.map(edge => {\n                const dimmed = related !== null && !related.edgeKeys.has(edge.key)\n                const tone = edge.active ? EDGE_TONE_ACTIVE : EDGE_TONE\n                return (\n                  <g\n                    className=\"transition-opacity duration-200 motion-reduce:transition-none\"\n                    key={edge.key}\n                    opacity={dimmed ? 0.15 : edge.active ? 1 : 0.5}\n                  >\n                    <path\n                      d={edge.d}\n                      fill=\"none\"\n                      markerEnd={`url(#${uid}-tip${edge.active ? \"-active\" : \"\"})`}\n                      strokeDasharray={edge.active ? \"7 6\" : edge.loop ? \"3 5\" : undefined}\n                      strokeLinecap=\"round\"\n                      strokeWidth={edge.active ? 2 : 1.5}\n                      style={{ stroke: tone }}\n                    >\n                      {/* SMIL, not a CSS keyframe: the component ships as one\n                          file with no stylesheet of its own, and the animation\n                          simply is not emitted when the reader asked for calm. */}\n                      {flowing && edge.active && (\n                        <animate\n                          attributeName=\"stroke-dashoffset\"\n                          dur=\"1.1s\"\n                          from=\"13\"\n                          repeatCount=\"indefinite\"\n                          to=\"0\"\n                        />\n                      )}\n                    </path>\n                    {showEdgeLabels && edge.label !== undefined && edge.label !== \"\" && (\n                      <>\n                        <rect\n                          className=\"fill-card\"\n                          height={EDGE_LABEL_SIZE + 5}\n                          rx={3}\n                          width={edge.label.length * EDGE_LABEL_SIZE * CHAR_RATIO + 8}\n                          x={r2(edge.labelAt.x - (edge.label.length * EDGE_LABEL_SIZE * CHAR_RATIO + 8) / 2)}\n                          y={r2(edge.labelAt.y - (EDGE_LABEL_SIZE + 5) / 2)}\n                        />\n                        <text\n                          className=\"fill-muted-foreground\"\n                          fontSize={EDGE_LABEL_SIZE}\n                          textAnchor=\"middle\"\n                          x={r2(edge.labelAt.x)}\n                          y={r2(edge.labelAt.y + EDGE_LABEL_SIZE * 0.35)}\n                        >\n                          {edge.label}\n                        </text>\n                      </>\n                    )}\n                  </g>\n                )\n              })}\n            </g>\n\n            <g role=\"presentation\">\n              {layout.nodes.map(placed => {\n                const node = placed.node\n                const tone = STATUS_TONE[node.status]\n                const TypeIcon = TYPE_ICON[node.type]\n                const StatusIcon = STATUS_ICON[node.status]\n                const shape = agentGraphShapePath(node.type, placed.x, placed.y, placed.width, placed.height)\n                const ring = agentGraphShapePath(\n                  node.type,\n                  placed.x - 4,\n                  placed.y - 4,\n                  placed.width + 8,\n                  placed.height + 8,\n                )\n                const wrapped = wrapGraphLabel(node.label, labelChars, lines)\n                const detail =\n                  node.detail === undefined || node.detail.trim() === \"\"\n                    ? { lines: [] as string[], truncated: false }\n                    : wrapGraphLabel(node.detail, detailChars, 1)\n                const blockHeight = wrapped.lines.length * LABEL_LINE + detail.lines.length * DETAIL_LINE\n                const top = placed.y + placed.height / 2 - blockHeight / 2\n                const centreY = placed.y + placed.height / 2\n                const isSelected = activeSelection === node.id\n                const isCursor = interactive && focused && cursor === node.id\n                const dimmed = related !== null && !related.nodeIds.has(node.id)\n\n                return (\n                  <g\n                    aria-label={\n                      interactive\n                        ? describeNode(\n                            node,\n                            wiring.incoming.get(node.id) ?? [],\n                            wiring.outgoing.get(node.id) ?? [],\n                          )\n                        : undefined\n                    }\n                    aria-selected={interactive ? isSelected : undefined}\n                    className={cn(\n                      \"transition-opacity duration-200 motion-reduce:transition-none\",\n                      interactive && \"cursor-pointer\",\n                    )}\n                    id={interactive ? optionId(node.id) : undefined}\n                    key={node.id}\n                    onClick={interactive ? () => selectNode(node) : undefined}\n                    onPointerEnter={highlightNeighbors ? () => setHovered(node.id) : undefined}\n                    // Guarded, because pointerleave on the old box lands before\n                    // pointerenter on the new one: an unguarded reset would blink\n                    // the whole diagram back to full opacity between neighbours.\n                    onPointerLeave={\n                      highlightNeighbors\n                        ? () => setHovered(current => (current === node.id ? null : current))\n                        : undefined\n                    }\n                    opacity={dimmed ? 0.22 : 1}\n                    role={interactive ? \"option\" : undefined}\n                    style={{ color: tone }}\n                  >\n                    {(wrapped.truncated || detail.truncated) && (\n                      // The full text survives the ellipsis: hover reads it, and\n                      // the accessible name above carries it regardless.\n                      <title>{node.detail ? `${node.label} — ${node.detail}` : node.label}</title>\n                    )}\n\n                    {/* A transparent hit area over the whole box, so the corners\n                        a hexagon or a stadium carves away still take the click.\n                        `fill=\"transparent\"` and not `fill=\"none\"`: only a painted\n                        fill is hit-tested, however invisible it is. */}\n                    <rect\n                      fill=\"transparent\"\n                      height={placed.height}\n                      width={placed.width}\n                      x={r2(placed.x)}\n                      y={r2(placed.y)}\n                    />\n\n                    {isCursor && (\n                      // Two rings: `ring` alone does not clear 3:1 against the\n                      // card in light mode, so the visible one is `foreground`\n                      // over a `background` halo that keeps it legible on a tint.\n                      <>\n                        <path className=\"fill-none stroke-background\" d={ring} strokeWidth={5} />\n                        <path className=\"fill-none stroke-foreground\" d={ring} strokeWidth={2} />\n                      </>\n                    )}\n                    {isSelected && !isCursor && (\n                      <path className=\"fill-none stroke-primary\" d={ring} strokeWidth={2} />\n                    )}\n\n                    <path\n                      className=\"fill-card stroke-current\"\n                      d={shape}\n                      strokeDasharray={node.status === \"skipped\" ? \"5 4\" : undefined}\n                      strokeOpacity={node.status === \"idle\" || node.status === \"skipped\" ? 0.5 : 0.95}\n                      strokeWidth={isSelected ? 2 : 1.4}\n                    />\n                    {node.status !== \"idle\" && node.status !== \"skipped\" && (\n                      <path className=\"fill-current\" d={shape} opacity={0.08} />\n                    )}\n\n                    <TypeIcon\n                      height={TYPE_ICON_SIZE}\n                      width={TYPE_ICON_SIZE}\n                      x={r2(placed.x + inset)}\n                      y={r2(centreY - TYPE_ICON_SIZE / 2)}\n                    />\n\n                    {wrapped.lines.map((line, index) => (\n                      <text\n                        className=\"fill-foreground\"\n                        fontSize={LABEL_SIZE}\n                        fontWeight={500}\n                        key={`label-${index}`}\n                        opacity={node.status === \"skipped\" ? 0.65 : 1}\n                        x={r2(placed.x + textLeft)}\n                        y={r2(top + LABEL_LINE * (index + 0.78))}\n                      >\n                        {line}\n                      </text>\n                    ))}\n                    {detail.lines.map((line, index) => (\n                      <text\n                        className=\"fill-muted-foreground\"\n                        fontSize={DETAIL_SIZE}\n                        key={`detail-${index}`}\n                        x={r2(placed.x + textLeft)}\n                        y={r2(top + wrapped.lines.length * LABEL_LINE + DETAIL_LINE * 0.78)}\n                      >\n                        {line}\n                      </text>\n                    ))}\n\n                    <StatusIcon\n                      height={STATUS_ICON_SIZE}\n                      width={STATUS_ICON_SIZE}\n                      x={r2(placed.x + placed.width - inset - STATUS_ICON_SIZE)}\n                      y={r2(centreY - STATUS_ICON_SIZE / 2)}\n                    />\n                  </g>\n                )\n              })}\n            </g>\n          </svg>\n        </div>\n\n        {!interactive && (\n          // A picture cannot be tabbed through, so the topology is read out\n          // instead — one sentence per box, inputs and outputs named.\n          <ol className=\"sr-only\" id={descriptionId}>\n            {layout.nodes.map(placed => (\n              <li key={placed.node.id}>\n                {describeNode(\n                  placed.node,\n                  wiring.incoming.get(placed.node.id) ?? [],\n                  wiring.outgoing.get(placed.node.id) ?? [],\n                )}\n              </li>\n            ))}\n          </ol>\n        )}\n\n        {showLegend && (\n          <ul\n            aria-label={`${label} key`}\n            className=\"flex flex-wrap items-center gap-x-4 gap-y-1.5 border-t px-3 py-2 text-[11px] text-muted-foreground\"\n          >\n            {typesPresent.map(type => {\n              const Icon = TYPE_ICON[type]\n              return (\n                <li className=\"flex items-center gap-1.5\" key={`type-${type}`}>\n                  <svg aria-hidden=\"true\" className=\"shrink-0\" height={12} viewBox=\"0 0 22 12\" width={22}>\n                    <path\n                      className=\"fill-none stroke-muted-foreground\"\n                      d={agentGraphShapePath(type, 1, 1, 20, 10)}\n                      strokeWidth={1.2}\n                    />\n                  </svg>\n                  <Icon aria-hidden=\"true\" className=\"size-3\" />\n                  {TYPE_WORD[type]}\n                </li>\n              )\n            })}\n            <li aria-hidden=\"true\" className=\"h-3 w-px shrink-0 bg-border\" />\n            {statusesPresent.map(nodeStatus => {\n              const Icon = STATUS_ICON[nodeStatus]\n              return (\n                <li className=\"flex items-center gap-1.5\" key={`status-${nodeStatus}`}>\n                  <Icon aria-hidden=\"true\" className=\"size-3 shrink-0\" style={{ color: STATUS_TONE[nodeStatus] }} />\n                  {STATUS_WORD[nodeStatus]}\n                </li>\n              )\n            })}\n            {layout.hasCycle && (\n              <li className=\"flex items-center gap-1.5\" key=\"loop\">\n                <svg aria-hidden=\"true\" className=\"shrink-0\" height={12} viewBox=\"0 0 22 12\" width={22}>\n                  <path\n                    className=\"fill-none stroke-muted-foreground\"\n                    d=\"M 3 4 C 3 11 19 11 19 4\"\n                    strokeDasharray=\"3 3\"\n                    strokeWidth={1.2}\n                  />\n                </svg>\n                loops back\n              </li>\n            )}\n          </ul>\n        )}\n      </>,\n    )\n  },\n)\n\nAgentGraph.displayName = \"AgentGraph\"\n\nexport default AgentGraph\n",
      "type": "registry:ui"
    },
    {
      "path": "src/registry/ui/agent-graph.contract.ts",
      "content": "import { z } from \"zod\"\n\n/**\n * The WIRING of an agent workflow — which boxes exist and what feeds what.\n *\n * This contract is deliberately topological and nothing else: there are no\n * coordinates in it, no widths, no \"collapsed\" flags. A workflow definition (or\n * a run of one) is a set of nodes and a set of directed edges; where those boxes\n * land on screen is a rendering decision the component makes from the topology,\n * and it has to stay that way — the moment a backend starts shipping `x`/`y` you\n * own a layout editor, and a layout editor is a different product from a picture\n * of a pipeline.\n *\n * Everything here is data. The component starts no clock, runs no simulation and\n * derives nothing from wall time, so the same payload renders identically on the\n * server, in a screenshot and in a replayed run.\n */\n\n/**\n * What KIND of box this is. The type picks the SHAPE the node is drawn with —\n * rounded box, notched box, hexagon, stadium — so the four kinds stay\n * distinguishable in a monochrome theme, in print, and for a colour-blind\n * reader. Colour is reserved for `status`, which is the thing that changes.\n *\n * - `agent`    a model doing work: a planner, a researcher, a critic.\n * - `tool`     a deterministic call: an HTTP fetch, a SQL query, an MCP tool.\n * - `decision` a branch: the outgoing edges are the alternatives, and their\n *              `label` is the condition (\"needs review\" / \"auto-approve\").\n * - `io`       a boundary of the flow: the user's message in, the answer out,\n *              a queue, a file drop. It is where the graph touches the world.\n */\nexport const AGENT_GRAPH_NODE_TYPES = [\"agent\", \"tool\", \"decision\", \"io\"] as const\nexport const agentGraphNodeTypeSchema = z.enum(AGENT_GRAPH_NODE_TYPES)\nexport type AgentGraphNodeType = z.infer<typeof agentGraphNodeTypeSchema>\n\n/**\n * How this box is doing in the run being displayed.\n *\n * `skipped` is NOT `idle`: an idle node is still going to run, a skipped one\n * never will (the branch went the other way). Collapsing them makes a decision\n * that was already taken look like a step that is merely pending. Each status is\n * drawn as a colour AND a distinct glyph, and `skipped` additionally gets a\n * dashed outline, because a picture that encodes state only as hue is a picture\n * half the readers cannot read.\n *\n * A definition that is not being run at all can leave every node `idle` — the\n * component is then a topology diagram, which is a perfectly good use of it.\n */\nexport const AGENT_GRAPH_NODE_STATUSES = [\"idle\", \"running\", \"done\", \"failed\", \"skipped\"] as const\nexport const agentGraphNodeStatusSchema = z.enum(AGENT_GRAPH_NODE_STATUSES)\nexport type AgentGraphNodeStatus = z.infer<typeof agentGraphNodeStatusSchema>\n\nexport const agentGraphNodeSchema = z.object({\n  /**\n   * Stable identity — the value edges point at, the React key, and what\n   * `onNodeSelect` hands back. Duplicate ids are dropped (first one wins)\n   * rather than drawn twice: two boxes claiming the same id would make every\n   * edge that names it ambiguous.\n   */\n  id: z.string().min(1),\n  /** The name on the box. Wrapped to `labelLines` lines and ellipsised — never silently clipped. */\n  label: z.string().min(1),\n  type: agentGraphNodeTypeSchema,\n  status: agentGraphNodeStatusSchema,\n  /**\n   * One line under the label: the model, the tool signature, the queue name.\n   * Pre-formatted by you — the component never composes it, and never truncates\n   * it invisibly (it is ellipsised, with the full text kept in the accessible\n   * name and in the native tooltip).\n   */\n  detail: z.string().optional(),\n})\nexport type AgentGraphNode = z.infer<typeof agentGraphNodeSchema>\n\nexport const agentGraphEdgeSchema = z.object({\n  /** Source node id. An edge naming an id that is not in `nodes` is dropped and counted, never drawn into the void. */\n  from: z.string().min(1),\n  /** Target node id. `from === to` is dropped too: a self-loop says nothing a status cannot say better. */\n  to: z.string().min(1),\n  /**\n   * There is traffic on this wire right now. Active wires are drawn in the\n   * accent tone with a dash that flows from source to target — and go still\n   * (dashed, but not moving) under `prefers-reduced-motion: reduce`, because the\n   * dash pattern already carries the meaning without the movement.\n   */\n  active: z.boolean().optional(),\n  /**\n   * The condition on a branch, drawn on the wire: \"yes\" / \"no\" / \"score < 0.6\".\n   * Mostly worth setting on a `decision` node's outgoing edges — an unlabelled\n   * fork is a diagram that has to be explained out loud.\n   */\n  label: z.string().optional(),\n})\nexport type AgentGraphEdge = z.infer<typeof agentGraphEdgeSchema>\n\n/**\n * The picture's own render state — \"is there a graph to draw at all\". It is\n * independent of any node's `status`: `status=\"error\"` means the workflow\n * definition failed to load, while a node whose status is `failed` is a step\n * that broke inside a graph that loaded perfectly.\n */\nexport const agentGraphStatusSchema = z.enum([\"loading\", \"empty\", \"error\", \"ready\"])\nexport type AgentGraphStatus = z.infer<typeof agentGraphStatusSchema>\n\n/** The envelope a data layer / mock factory hands over; the demo spreads it straight into the props. */\nexport const agentGraphSchema = z.object({\n  status: agentGraphStatusSchema,\n  nodes: z.array(agentGraphNodeSchema),\n  edges: z.array(agentGraphEdgeSchema),\n})\nexport type AgentGraphData = z.infer<typeof agentGraphSchema>\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}