{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "agent-graph",
  "title": "Agent Graph",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A layered graph of how agents, tools, memory and people hand work to each other: nodes that say what they are and what they are doing in words, labelled arrows, the active path drawn heavier, and arrow keys that follow the edges.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/agent-graph.tsx",
      "content": "// Ballmac UI: Agent Graph. https://ui.ballmac.com/components/agent-graph\n\"use client\"\n\nimport * as React from \"react\"\nimport { Bot, Brain, CircleAlert, CircleCheck, CircleDot, Hourglass, User, Wrench, type LucideIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype GraphKind = \"agent\" | \"tool\" | \"human\" | \"memory\"\ntype GraphStatus = \"idle\" | \"working\" | \"waiting\" | \"blocked\" | \"done\"\n\ntype GraphNode = {\n  id: string\n  label: string\n  kind?: GraphKind\n  status?: GraphStatus\n  /** A line shown under the graph when the node is selected. */\n  detail?: string\n}\n\ntype GraphEdge = {\n  from: string\n  to: string\n  /** Short text on the edge, such as \"plan\" or \"asks\". */\n  label?: string\n  /** Draws the edge heavier, for the path the work is taking now. */\n  active?: boolean\n}\n\ntype Placed = GraphNode & { col: number; row: number; x: number; y: number }\n\nconst NODE_W = 168\nconst NODE_H = 58\nconst COL_GAP = 84\nconst ROW_GAP = 22\nconst PAD = 12\n\n/** Finds the edges that point back to a node still being explored (the ones that close a loop), so layering can ignore them. Returns their indexes. */\nfunction backEdges(ids: string[], edges: { from: string; to: string }[]) {\n  const state = new Map<string, 1 | 2>()\n  const back = new Set<number>()\n  const visit = (id: string) => {\n    state.set(id, 1)\n    edges.forEach((e, i) => {\n      if (e.from !== id) return\n      const s = state.get(e.to)\n      if (s === 1) back.add(i)\n      else if (!s) visit(e.to)\n    })\n    state.set(id, 2)\n  }\n  const hasIn = new Set(edges.map((e) => e.to))\n  for (const id of [...ids.filter((x) => !hasIn.has(x)), ...ids]) if (!state.has(id)) visit(id)\n  return back\n}\n\n/**\n * Places nodes in columns left to right (a node goes after everything that feeds it) and spreads each column evenly, then returns the nodes with pixel\n * positions and the curve for every edge. Pure, so it can be tested and reused. Edges that loop back are drawn as curves under the nodes.\n */\nfunction layoutGraph(nodes: GraphNode[], edges: GraphEdge[]) {\n  const ids = new Set(nodes.map((n) => n.id))\n  const good = edges.filter((e) => ids.has(e.from) && ids.has(e.to) && e.from !== e.to)\n  const loops = backEdges(nodes.map((n) => n.id), good)\n  const forward = good.filter((_, i) => !loops.has(i))\n  const col = new Map<string, number>(nodes.map((n) => [n.id, 0]))\n  const indeg = new Map<string, number>(nodes.map((n) => [n.id, forward.filter((e) => e.to === n.id).length]))\n  const queue = nodes.filter((n) => indeg.get(n.id) === 0).map((n) => n.id)\n  const seen = new Set<string>()\n  while (queue.length) {\n    const id = queue.shift()!\n    seen.add(id)\n    for (const e of forward.filter((x) => x.from === id)) {\n      col.set(e.to, Math.max(col.get(e.to) ?? 0, (col.get(id) ?? 0) + 1))\n      indeg.set(e.to, (indeg.get(e.to) ?? 1) - 1)\n      if (indeg.get(e.to) === 0) queue.push(e.to)\n    }\n  }\n  const depth = Math.max(0, ...col.values())\n  const cols: string[][] = Array.from({ length: depth + 1 }, () => [])\n  for (const n of nodes) cols[col.get(n.id) ?? 0].push(n.id)\n  const rows = Math.max(...cols.map((c) => c.length), 1)\n  const height = PAD * 2 + rows * NODE_H + (rows - 1) * ROW_GAP\n  const width = PAD * 2 + (depth + 1) * NODE_W + depth * COL_GAP\n  const placed = new Map<string, Placed>()\n  cols.forEach((c, ci) => {\n    const used = c.length * NODE_H + (c.length - 1) * ROW_GAP\n    const top = PAD + (height - PAD * 2 - used) / 2\n    c.forEach((id, ri) => {\n      const n = nodes.find((x) => x.id === id)!\n      placed.set(id, { ...n, col: ci, row: ri, x: PAD + ci * (NODE_W + COL_GAP), y: top + ri * (NODE_H + ROW_GAP) })\n    })\n  })\n  const paths = good.map((e) => {\n    const a = placed.get(e.from)!\n    const b = placed.get(e.to)!\n    const back = b.col <= a.col\n    const x0 = back ? a.x + NODE_W / 2 : a.x + NODE_W\n    const y0 = back ? a.y + NODE_H : a.y + NODE_H / 2\n    const x1 = back ? b.x + NODE_W / 2 : b.x\n    const y1 = back ? b.y + NODE_H : b.y + NODE_H / 2\n    const mid = (x0 + x1) / 2\n    const d = back ? `M ${x0} ${y0} C ${x0} ${y0 + 56}, ${x1} ${y1 + 56}, ${x1} ${y1}` : `M ${x0} ${y0} C ${mid} ${y0}, ${mid} ${y1}, ${x1} ${y1}`\n    const lx = back ? (x0 + x1) / 2 : mid\n    const ly = back ? Math.max(y0, y1) + 42 : (y0 + y1) / 2\n    return { ...e, d, lx, ly }\n  })\n  return { nodes: [...placed.values()], edges: paths, width, height, depth }\n}\n\nconst KINDS: Record<GraphKind, { icon: LucideIcon; label: string }> = {\n  agent: { icon: Bot, label: \"Agent\" },\n  tool: { icon: Wrench, label: \"Tool\" },\n  human: { icon: User, label: \"Person\" },\n  memory: { icon: Brain, label: \"Memory\" },\n}\nconst STATUS: Record<GraphStatus, { icon: LucideIcon; label: string }> = {\n  idle: { icon: CircleDot, label: \"Idle\" },\n  working: { icon: CircleDot, label: \"Working\" },\n  waiting: { icon: Hourglass, label: \"Waiting\" },\n  blocked: { icon: CircleAlert, label: \"Blocked\" },\n  done: { icon: CircleCheck, label: \"Done\" },\n}\n\ntype AgentGraphProps = Omit<React.ComponentProps<\"div\">, \"onSelect\"> & {\n  nodes: GraphNode[]\n  edges: GraphEdge[]\n  /** The selected node id. Controlled. */\n  selected?: string | null\n  /** The selected node id for uncontrolled use. */\n  defaultSelected?: string | null\n  /** Called when a node is chosen, or null when it is cleared. */\n  onSelectedChange?: (id: string | null) => void\n}\n\n/**\n * How a team of agents and tools is wired: who sends work to whom. Nodes are buttons showing what they are and what they are doing in words; the arrow keys\n * follow the edges (Right to what a node sends to, Left to what feeds it, Up and Down within a column), and the connections of the chosen node are listed\n * in a sentence. The graph reads left to right and scrolls sideways on a narrow screen.\n */\nfunction AgentGraph({ nodes, edges, selected: selectedProp, defaultSelected = null, onSelectedChange, className, ...props }: AgentGraphProps) {\n  const msg = useMessages()\n  const layout = React.useMemo(() => layoutGraph(nodes, edges), [nodes, edges])\n  const byId = new Map(layout.nodes.map((n) => [n.id, n]))\n  const [inner, setInner] = React.useState<string | null>(defaultSelected)\n  const selected = selectedProp !== undefined ? selectedProp : inner\n  const chosen = selected ? byId.get(selected) : undefined\n  const [focus, setFocus] = React.useState<string | null>(null)\n  const ordered = [...layout.nodes].sort((a, b) => a.col - b.col || a.row - b.row)\n  const tabbable = (focus && byId.has(focus) ? focus : selected && byId.has(selected) ? selected : ordered[0]?.id) ?? null\n  const refs = React.useRef(new Map<string, HTMLButtonElement>())\n  const outOf = (id: string) => layout.edges.filter((e) => e.from === id).map((e) => byId.get(e.to)!).filter(Boolean)\n  const into = (id: string) => layout.edges.filter((e) => e.to === id).map((e) => byId.get(e.from)!).filter(Boolean)\n  const names = (l: Placed[]) => l.map((n) => n.label).join(\", \")\n  const lit = (id: string) => !chosen || id === chosen.id || outOf(chosen.id).some((n) => n.id === id) || into(chosen.id).some((n) => n.id === id)\n\n  function pick(id: string) {\n    const next = id === selected ? null : id\n    if (selectedProp === undefined) setInner(next)\n    onSelectedChange?.(next)\n  }\n  function onKeyDown(e: React.KeyboardEvent, n: Placed) {\n    const near = (list: Placed[]) => [...list].sort((a, b) => Math.abs(a.y - n.y) - Math.abs(b.y - n.y))[0]\n    const sameCol = ordered.filter((o) => o.col === n.col)\n    const i = sameCol.indexOf(n)\n    const to = e.key === \"ArrowRight\" ? near(outOf(n.id)) : e.key === \"ArrowLeft\" ? near(into(n.id)) : e.key === \"ArrowDown\" ? sameCol[i + 1] : e.key === \"ArrowUp\" ? sameCol[i - 1] : e.key === \"Home\" ? ordered[0] : e.key === \"End\" ? ordered[ordered.length - 1] : undefined\n    if (to) {\n      e.preventDefault()\n      setFocus(to.id)\n      refs.current.get(to.id)?.focus()\n    }\n  }\n\n  return (\n    <div data-slot=\"agent-graph\" className={cn(\"grid w-full min-w-0 gap-3\", className)} {...props}>\n      <div dir=\"ltr\" tabIndex={-1} className=\"overflow-x-auto rounded-xl border bg-card\">\n        <div className=\"relative mx-auto\" style={{ width: layout.width, height: layout.height }} role=\"group\" aria-label={msg(\"agent-graph.label\", \"How the agents and tools are connected\")}>\n          <svg aria-hidden=\"true\" width={layout.width} height={layout.height} className=\"absolute inset-0 text-foreground\">\n            <defs>\n              <marker id=\"ag-arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\">\n                <path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"currentColor\" />\n              </marker>\n            </defs>\n            {layout.edges.map((e) => {\n              const dim = chosen && !(e.from === chosen.id || e.to === chosen.id)\n              return <path key={`${e.from}-${e.to}`} d={e.d} fill=\"none\" stroke=\"currentColor\" strokeWidth={e.active ? 2.5 : 1.5} strokeOpacity={dim ? 0.12 : e.active ? 0.9 : 0.4} markerEnd=\"url(#ag-arrow)\" strokeLinecap=\"round\" />\n            })}\n          </svg>\n          {layout.edges.filter((e) => e.label).map((e) => (\n            <span key={`l-${e.from}-${e.to}`} className=\"pointer-events-none absolute -translate-x-1/2 -translate-y-1/2 rounded-full border bg-card px-2 py-0.5 text-[11px] font-medium text-muted-foreground\" style={{ left: e.lx, top: e.ly }}>\n              {e.label}\n            </span>\n          ))}\n          {layout.nodes.map((n) => {\n            const K = KINDS[n.kind ?? \"agent\"]\n            const S = n.status ? STATUS[n.status] : null\n            const on = selected === n.id\n            return (\n              <button\n                key={n.id}\n                ref={(el) => {\n                  if (el) refs.current.set(n.id, el)\n                  else refs.current.delete(n.id)\n                }}\n                type=\"button\"\n                aria-pressed={on}\n                tabIndex={tabbable === n.id ? 0 : -1}\n                aria-label={msg(\"agent-graph.node\", \"{label}, {kind}{status}\", { label: n.label, kind: msg(`agent-graph.kind.${n.kind ?? \"agent\"}`, K.label).toLowerCase(), status: S ? `, ${msg(`agent-graph.status.${n.status}`, S.label).toLowerCase()}` : \"\" })}\n                onClick={() => pick(n.id)}\n                onFocus={() => setFocus(n.id)}\n                onKeyDown={(e) => onKeyDown(e, n)}\n                className={cn(\n                  \"absolute flex items-center gap-2.5 rounded-xl border bg-card px-3 text-start outline-none transition-[box-shadow,opacity,border-color] duration-150 focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none\",\n                  n.status === \"blocked\" && \"border-destructive/60\",\n                  on ? \"border-foreground shadow-[0_0_0_1px_var(--foreground)]\" : \"hover:border-foreground/40\",\n                  !lit(n.id) && \"opacity-45\"\n                )}\n                style={{ left: n.x, top: n.y, width: NODE_W, height: NODE_H }}\n              >\n                <span aria-hidden=\"true\" className=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted\">\n                  <K.icon className=\"size-4\" />\n                </span>\n                <span className=\"min-w-0\">\n                  <span className=\"block truncate text-[13px] font-medium text-foreground\">{n.label}</span>\n                  <span className={cn(\"flex items-center gap-1 text-[11px]\", n.status === \"blocked\" ? \"font-medium text-destructive\" : \"text-muted-foreground\")}>\n                    {S ? <S.icon aria-hidden=\"true\" className=\"size-3 shrink-0\" /> : null}\n                    <span className=\"truncate\">{S ? msg(`agent-graph.status.${n.status}`, S.label) : msg(`agent-graph.kind.${n.kind ?? \"agent\"}`, K.label)}</span>\n                  </span>\n                </span>\n              </button>\n            )\n          })}\n        </div>\n      </div>\n\n      <div aria-live=\"polite\" className=\"min-h-[3rem] rounded-xl border bg-card p-4 text-sm\">\n        {chosen ? (\n          <div className=\"grid gap-1\">\n            <p className=\"font-medium\">{chosen.label} <span className=\"font-normal text-muted-foreground\">· {msg(`agent-graph.kind.${chosen.kind ?? \"agent\"}`, KINDS[chosen.kind ?? \"agent\"].label)}{chosen.status ? `, ${msg(`agent-graph.status.${chosen.status}`, STATUS[chosen.status].label).toLowerCase()}` : \"\"}</span></p>\n            {chosen.detail && <p className=\"text-muted-foreground\">{chosen.detail}</p>}\n            <p className=\"text-muted-foreground\">\n              {into(chosen.id).length ? msg(\"agent-graph.receives\", \"Receives from {names}.\", { names: names(into(chosen.id)) }) : msg(\"agent-graph.starts\", \"Starts the work.\")}{\" \"}\n              {outOf(chosen.id).length ? msg(\"agent-graph.sends\", \"Sends to {names}.\", { names: names(outOf(chosen.id)) }) : msg(\"agent-graph.ends\", \"Nothing follows it.\")}\n            </p>\n          </div>\n        ) : (\n          <p className=\"text-muted-foreground\">{msg(\"agent-graph.hint\", \"Select a node to see its connections. The arrow keys follow the edges.\")}</p>\n        )}\n      </div>\n    </div>\n  )\n}\n\nexport { AgentGraph, layoutGraph, type AgentGraphProps, type GraphEdge, type GraphKind, type GraphNode, type GraphStatus }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/agent-graph.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "agents",
      "graph",
      "multi-agent",
      "workflow",
      "orchestration",
      "diagram",
      "topology"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "nodes is [{ id, label, kind?: agent | tool | human | memory, status?: idle | working | waiting | blocked | done, detail? }]; edges is [{ from, to, label?, active? }]. Columns are computed from the edges; an edge that loops back is drawn under the nodes. selected / defaultSelected / onSelectedChange. layoutGraph(nodes, edges) is exported.",
      "whenToUse": [
        "Showing how a multi-agent system is wired and where the work is right now",
        "A map of tools and people an agent can call"
      ],
      "whenNotToUse": [
        "Who did what over time; use agent-lanes",
        "A linear list of steps; use trace or agent-plan",
        "Flows with amounts; use flow-sankey"
      ],
      "composesWith": [
        "agent-roster",
        "agent-lanes",
        "handoff-card",
        "tool-catalog"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Enters the graph once, on the first node (or the selected one)"
        },
        {
          "keys": "ArrowRight / ArrowLeft",
          "action": "Follow an edge to what the node sends to, or to what feeds it"
        },
        {
          "keys": "ArrowDown / ArrowUp",
          "action": "Next or previous node in the same column; Home and End go to the first and last"
        },
        {
          "keys": "Enter / Space",
          "action": "Selects the node and lists its connections in a sentence (a polite live region)"
        },
        {
          "keys": "Screen readers",
          "action": "Each node reads 'Coder, agent, working'; status and kind are words, never colour or position alone"
        }
      ],
      "customization": [
        "nodes with kind and status",
        "edges with labels and active",
        "Controlled selection",
        "layoutGraph is exported"
      ]
    },
    "statesNone": "A presentational diagram: you pass the nodes and edges in, so it has nothing of its own to load or fail.",
    "examples": [
      "agent-graph-demo",
      "agent-graph-review"
    ],
    "url": "https://ui.ballmac.com/components/agent-graph"
  },
  "categories": [
    "ai"
  ],
  "type": "registry:ui"
}