{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "agent-lanes",
  "title": "Agent Lanes",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A swimlane timeline for multi-agent runs: one lane per agent, steps coloured by what they are doing, dashed handoffs between lanes, a Now line and a detail panel, all reachable with the arrow keys.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/direction.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/agent-lanes.tsx",
      "content": "// Ballmac UI: Agent Lanes. https://ui.ballmac.com/components/agent-lanes\n\"use client\"\n\nimport * as React from \"react\"\nimport { AlertOctagon, ArrowRight, CheckCircle2, Hourglass, Sparkles, Wrench, type LucideIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useDirection } from \"@/lib/ballmac/direction\"\nimport { useMessages, type Msg } from \"@/lib/ballmac/i18n\"\n\ntype LaneKind = \"thinking\" | \"tool\" | \"waiting\" | \"blocked\" | \"done\"\n\ntype LaneSegment = {\n  /** Unique across all lanes. Handoffs and selection refer to it. */\n  id: string\n  /** Start, in seconds from the beginning of the run. */\n  start: number\n  /** End, in seconds. */\n  end: number\n  /** What the agent is doing: thinking, calling a tool, waiting on someone, blocked, or done. */\n  kind: LaneKind\n  /** Short text shown on the bar and read to assistive technology. */\n  label: string\n  /** More detail, shown in the panel under the chart when the step is selected. */\n  detail?: string\n}\n\ntype Lane = {\n  id: string\n  /** The agent's name. */\n  name: string\n  /** What it is for, for example \"Writes the code\". */\n  role?: string\n  segments: LaneSegment[]\n}\n\ntype Handoff = {\n  /** The segment that hands the work over. */\n  from: string\n  /** The segment that takes it. */\n  to: string\n  /** Optional note drawn on the connector, such as \"plan\". */\n  label?: string\n}\n\nconst KINDS: Record<LaneKind, { icon: LucideIcon; bar: string; fallback: string }> = {\n  thinking: { icon: Sparkles, bar: \"border-chart-1/40 bg-chart-1/15\", fallback: \"Thinking\" },\n  tool: { icon: Wrench, bar: \"border-chart-2/40 bg-chart-2/15\", fallback: \"Using a tool\" },\n  waiting: { icon: Hourglass, bar: \"border-border bg-[repeating-linear-gradient(135deg,var(--muted)_0_4px,transparent_4px_8px)]\", fallback: \"Waiting\" },\n  blocked: { icon: AlertOctagon, bar: \"border-destructive/50 bg-destructive/10\", fallback: \"Blocked\" },\n  done: { icon: CheckCircle2, bar: \"border-chart-4/40 bg-chart-4/15\", fallback: \"Done\" },\n}\n\nconst ROW = 44\nconst HEAD = 32\nconst NAME_W = \"9rem\"\nconst STEPS = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1200, 3600]\n\n/** 75 → \"1:15\", 3725 → \"1:02:05\". */\nfunction formatClock(seconds: number) {\n  const s = Math.max(0, Math.round(seconds))\n  const h = Math.floor(s / 3600)\n  const m = Math.floor((s % 3600) / 60)\n  const r = s % 60\n  return h > 0 ? `${h}:${String(m).padStart(2, \"0\")}:${String(r).padStart(2, \"0\")}` : `${m}:${String(r).padStart(2, \"0\")}`\n}\n\n/** The smallest step that keeps the axis to about eight labels. */\nfunction tickStep(duration: number) {\n  return STEPS.find((s) => duration / s <= 8) ?? STEPS[STEPS.length - 1]\n}\n\nfunction kindText(msg: Msg, kind: LaneKind) {\n  return msg(`agent-lanes.kind.${kind}`, KINDS[kind].fallback)\n}\n\ntype AgentLanesProps = Omit<React.ComponentProps<\"div\">, \"onSelect\"> & {\n  /** One row per agent, each with its steps in time order. */\n  lanes: Lane[]\n  /** Where one agent hands work to another. Drawn as a dashed connector and listed in words. */\n  handoffs?: Handoff[]\n  /** Length of the run in seconds. The axis ends here. Defaults to the end of the last step. */\n  duration?: number\n  /** Where the run is now, in seconds. Draws a \"Now\" line. */\n  now?: number\n  /** The selected step id. Controlled. */\n  selected?: string | null\n  /** The selected step id for uncontrolled use. */\n  defaultSelected?: string | null\n  /** Called when a step is selected. */\n  onSelectedChange?: (id: string) => void\n  /** Turns seconds into text for the axis and the panel. Defaults to m:ss. */\n  formatTime?: (seconds: number) => string\n  /** Wider values make the chart scroll sideways earlier on small screens. Rem. */\n  minTrackWidth?: number\n}\n\nfunction AgentLanes({\n  lanes,\n  handoffs = [],\n  duration,\n  now,\n  selected: selectedProp,\n  defaultSelected = null,\n  onSelectedChange,\n  formatTime = formatClock,\n  minTrackWidth = 34,\n  dir,\n  className,\n  ...props\n}: AgentLanesProps) {\n  const msg = useMessages()\n  const direction = useDirection(dir === \"rtl\" || dir === \"ltr\" ? dir : undefined)\n  const [inner, setInner] = React.useState<string | null>(defaultSelected)\n  const selected = selectedProp !== undefined ? selectedProp : inner\n  const total = Math.max(duration ?? 0, ...lanes.flatMap((l) => l.segments.map((s) => s.end)), 1)\n  const pct = (t: number) => `${(Math.min(Math.max(t, 0), total) / total) * 100}%`\n\n  const index = React.useMemo(() => {\n    const map = new Map<string, { lane: number; seg: LaneSegment }>()\n    lanes.forEach((l, li) => l.segments.forEach((s) => map.set(s.id, { lane: li, seg: s })))\n    return map\n  }, [lanes])\n  const ordered = React.useMemo(() => lanes.map((l) => [...l.segments].sort((a, b) => a.start - b.start)), [lanes])\n\n  // Only one step is in the tab order (roving tabindex); it follows selection and arrow keys.\n  const first = ordered.flat()[0]?.id ?? null\n  const [focusId, setFocusId] = React.useState<string | null>(null)\n  const tabbable = (focusId && index.has(focusId) ? focusId : selected && index.has(selected) ? selected : first) ?? null\n  const refs = React.useRef(new Map<string, HTMLButtonElement>())\n  const move = (id: string) => {\n    setFocusId(id)\n    refs.current.get(id)?.focus()\n  }\n\n  function select(id: string) {\n    if (selectedProp === undefined) setInner(id)\n    onSelectedChange?.(id)\n  }\n\n  function onKeyDown(e: React.KeyboardEvent, id: string) {\n    const at = index.get(id)\n    if (!at) return\n    const row = ordered[at.lane]\n    const i = row.findIndex((s) => s.id === id)\n    const rtl = direction === \"rtl\"\n    let next: string | undefined\n    if (e.key === (rtl ? \"ArrowLeft\" : \"ArrowRight\")) next = row[i + 1]?.id\n    else if (e.key === (rtl ? \"ArrowRight\" : \"ArrowLeft\")) next = row[i - 1]?.id\n    else if (e.key === \"Home\") next = e.ctrlKey ? ordered.flat()[0]?.id : row[0]?.id\n    else if (e.key === \"End\") next = e.ctrlKey ? ordered.flat().at(-1)?.id : row.at(-1)?.id\n    else if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n      // The step in the next lane that overlaps this one most, or the one that starts closest to it.\n      for (let l = at.lane + (e.key === \"ArrowDown\" ? 1 : -1); l >= 0 && l < ordered.length; l += e.key === \"ArrowDown\" ? 1 : -1) {\n        const mid = (at.seg.start + at.seg.end) / 2\n        const candidates = ordered[l]\n        if (!candidates.length) continue\n        next = [...candidates].sort((a, b) => Math.abs((a.start + a.end) / 2 - mid) - Math.abs((b.start + b.end) / 2 - mid))[0].id\n        break\n      }\n    }\n    if (e.key.startsWith(\"Arrow\") || e.key === \"Home\" || e.key === \"End\") e.preventDefault()\n    if (next) move(next)\n  }\n\n  const step = tickStep(total)\n  const ticks: number[] = []\n  for (let t = 0; t <= total; t += step) ticks.push(t)\n\n  const sel = selected ? index.get(selected) : undefined\n  const handoffsOf = (id: string) => handoffs.filter((h) => h.from === id || h.to === id)\n  const nameOf = (id: string) => lanes[index.get(id)?.lane ?? 0]?.name ?? \"\"\n\n  return (\n    <div data-slot=\"agent-lanes\" dir={dir} className={cn(\"grid w-full min-w-0 gap-3\", className)} {...props}>\n      <div className=\"overflow-x-auto rounded-xl border bg-card text-card-foreground\">\n        <div className=\"relative\" style={{ minWidth: `calc(${NAME_W} + ${minTrackWidth}rem)` }}>\n          {/* Axis */}\n          <div className=\"flex border-b\" style={{ height: HEAD }}>\n            <div className=\"sticky start-0 z-20 shrink-0 border-e bg-card\" style={{ width: NAME_W }} />\n            <div aria-hidden=\"true\" className=\"relative grow\">\n              {ticks.map((t) => (\n                <span\n                  key={t}\n                  className={cn(\n                    \"absolute top-0 flex h-full items-center font-mono text-[11px] text-muted-foreground tabular-nums\",\n                    // The first label starts at the axis and the last ends at it, so neither is clipped by the names column or the edge.\n                    t === 0 ? \"ps-1.5\" : t / total > 0.96 ? \"-translate-x-full pe-1.5 rtl:translate-x-full\" : \"-translate-x-1/2 rtl:translate-x-1/2\"\n                  )}\n                  style={{ insetInlineStart: pct(t) }}\n                >\n                  {formatTime(t)}\n                </span>\n              ))}\n            </div>\n          </div>\n\n          {/* Lanes */}\n          {lanes.map((lane, li) => (\n            <div key={lane.id} role=\"group\" aria-label={lane.role ? `${lane.name}, ${lane.role}` : lane.name} className=\"flex border-b last:border-b-0\" style={{ height: ROW }}>\n              <div className=\"sticky start-0 z-20 flex shrink-0 flex-col justify-center border-e bg-card px-3\" style={{ width: NAME_W }}>\n                <span className=\"truncate text-[13px] font-medium text-foreground\">{lane.name}</span>\n                {lane.role && <span className=\"truncate text-[11px] text-muted-foreground\">{lane.role}</span>}\n              </div>\n              <div className=\"relative grow\">\n                {/* Grid lines */}\n                {ticks.map((t) => (\n                  <span key={t} aria-hidden=\"true\" className=\"absolute inset-y-0 w-px bg-border/60\" style={{ insetInlineStart: pct(t) }} />\n                ))}\n                {lane.segments.map((s) => {\n                  const k = KINDS[s.kind]\n                  const Icon = k.icon\n                  const width = ((Math.max(s.end - s.start, 0)) / total) * 100\n                  const active = selected === s.id\n                  return (\n                    <button\n                      key={s.id}\n                      ref={(el) => {\n                        if (el) refs.current.set(s.id, el)\n                        else refs.current.delete(s.id)\n                      }}\n                      type=\"button\"\n                      data-kind={s.kind}\n                      data-selected={active || undefined}\n                      tabIndex={tabbable === s.id ? 0 : -1}\n                      aria-pressed={active}\n                      aria-label={msg(\"agent-lanes.step\", \"{label}, {lane}, {kind}, {from} to {to}\", { label: s.label, lane: lane.name, kind: kindText(msg, s.kind), from: formatTime(s.start), to: formatTime(s.end) })}\n                      onClick={() => select(s.id)}\n                      onFocus={() => setFocusId(s.id)}\n                      onKeyDown={(e) => onKeyDown(e, s.id)}\n                      className={cn(\n                        \"absolute top-1.5 bottom-1.5 flex min-w-1.5 items-center gap-1.5 overflow-hidden rounded-md border px-1.5 text-start text-[12px] text-foreground outline-none transition-[box-shadow,transform] duration-150\",\n                        \"hover:shadow-sm focus-visible:z-10 focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n                        \"data-[selected]:z-10 data-[selected]:ring-2 data-[selected]:ring-foreground/70 motion-reduce:transition-none\",\n                        k.bar\n                      )}\n                      style={{ insetInlineStart: pct(s.start), width: `${width}%` }}\n                    >\n                      {width >= 4 && <Icon aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />}\n                      {width >= 12 && <span className=\"truncate\">{s.label}</span>}\n                    </button>\n                  )\n                })}\n              </div>\n            </div>\n          ))}\n\n          {/* Handoffs and the \"Now\" line sit over the lanes, to the right of the names column */}\n          <div aria-hidden=\"true\" className=\"pointer-events-none absolute end-0 bottom-0\" style={{ insetInlineStart: NAME_W, top: HEAD }}>\n            {handoffs.map((h) => {\n              const a = index.get(h.from)\n              const b = index.get(h.to)\n              if (!a || !b || a.lane === b.lane) return null\n              const up = b.lane < a.lane\n              const top = Math.min(a.lane, b.lane) * ROW + ROW / 2\n              const height = Math.abs(b.lane - a.lane) * ROW\n              return (\n                <span key={`${h.from}-${h.to}`} className=\"absolute w-0 border-s-2 border-dashed border-foreground/45\" style={{ insetInlineStart: pct(b.seg.start), top, height }}>\n                  <span className={cn(\"absolute -translate-x-1/2 border-x-[5px] border-x-transparent rtl:translate-x-1/2\", up ? \"-top-px border-b-[7px] border-b-foreground/60\" : \"-bottom-px border-t-[7px] border-t-foreground/60\")} />\n                  {h.label && <span className=\"absolute top-1/2 ms-2 -translate-y-1/2 rounded bg-card px-1.5 py-0.5 text-[10px] font-medium whitespace-nowrap text-muted-foreground ring-1 ring-border\">{h.label}</span>}\n                </span>\n              )\n            })}\n            {now !== undefined && now >= 0 && now <= total && (\n              <span className=\"absolute inset-y-0 w-0 border-s-2 border-primary\" style={{ insetInlineStart: pct(now) }}>\n                <span className=\"absolute -top-px -translate-x-1/2 rounded-b bg-primary px-1.5 py-0.5 font-mono text-[10px] font-medium text-primary-foreground rtl:translate-x-1/2\">{msg(\"agent-lanes.now\", \"Now\")}</span>\n              </span>\n            )}\n          </div>\n        </div>\n      </div>\n\n      {/* The same handoffs in words, for assistive technology and for anyone who prefers reading */}\n      {handoffs.length > 0 && (\n        <ul className=\"sr-only\">\n          {handoffs.map((h) => (\n            <li key={`${h.from}-${h.to}`}>{msg(\"agent-lanes.handoffText\", \"{from} hands over to {to} at {time}\", { from: nameOf(h.from), to: nameOf(h.to), time: formatTime(index.get(h.to)?.seg.start ?? 0) })}</li>\n          ))}\n        </ul>\n      )}\n\n      {/* Legend */}\n      <ul className=\"flex flex-wrap gap-x-4 gap-y-1.5 text-xs text-muted-foreground\" aria-label={msg(\"agent-lanes.legend\", \"Step kinds\")}>\n        {(Object.keys(KINDS) as LaneKind[]).map((kind) => {\n          const Icon = KINDS[kind].icon\n          return (\n            <li key={kind} className=\"flex items-center gap-1.5\">\n              <span aria-hidden=\"true\" className={cn(\"flex size-4 items-center justify-center rounded border\", KINDS[kind].bar)}>\n                <Icon className=\"size-2.5\" />\n              </span>\n              {kindText(msg, kind)}\n            </li>\n          )\n        })}\n      </ul>\n\n      {/* Detail of the selected step */}\n      <div aria-live=\"polite\" className=\"min-h-[5.5rem] rounded-xl border bg-card p-4 text-card-foreground\">\n        {sel ? (\n          <div data-slot=\"agent-lanes-detail\" className=\"grid gap-1.5\">\n            <p className=\"flex flex-wrap items-center gap-x-2 gap-y-1 text-sm font-medium\">\n              {sel.seg.label}\n              <span className=\"rounded-full border px-2 py-0.5 text-[11px] font-normal text-muted-foreground\">{kindText(msg, sel.seg.kind)}</span>\n            </p>\n            <p className=\"font-mono text-xs text-muted-foreground tabular-nums\">\n              {lanes[sel.lane].name} · {formatTime(sel.seg.start)} <ArrowRight aria-hidden=\"true\" className=\"inline size-3 rtl:rotate-180\" /> {formatTime(sel.seg.end)} · {formatTime(sel.seg.end - sel.seg.start)}\n            </p>\n            {sel.seg.detail && <p className=\"text-[13px] leading-relaxed text-muted-foreground\">{sel.seg.detail}</p>}\n            {handoffsOf(sel.seg.id).map((h) => (\n              <p key={`${h.from}-${h.to}`} className=\"text-[13px] text-foreground\">\n                {h.from === sel.seg.id\n                  ? msg(\"agent-lanes.handsTo\", \"Hands over to {to}.\", { to: nameOf(h.to) })\n                  : msg(\"agent-lanes.handedBy\", \"Started by {from}.\", { from: nameOf(h.from) })}\n                {h.label ? ` (${h.label})` : \"\"}\n              </p>\n            ))}\n          </div>\n        ) : (\n          <p className=\"text-[13px] text-muted-foreground\">{msg(\"agent-lanes.hint\", \"Select a step to see what happened. Arrow keys move between steps.\")}</p>\n        )}\n      </div>\n    </div>\n  )\n}\n\nexport { AgentLanes, formatClock, type AgentLanesProps, type Lane, type LaneKind, type LaneSegment, type Handoff }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/agent-lanes.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "agents",
      "timeline",
      "swimlane",
      "handoff",
      "multi-agent",
      "trace"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "lanes is [{ id, name, role?, segments: [{ id, start, end, kind, label, detail? }] }] with times in seconds; kind is thinking | tool | waiting | blocked | done. handoffs is [{ from, to, label? }] using segment ids. Optional duration, now, selected / defaultSelected / onSelectedChange, formatTime.",
      "whenToUse": [
        "Showing what several agents did and when, and who handed work to whom",
        "Finding the step where a run waited or got blocked"
      ],
      "whenNotToUse": [
        "One agent's linear steps; use trace",
        "A plan with checkable steps; use agent-plan",
        "A project schedule; use the gantt block"
      ],
      "composesWith": [
        "trace",
        "token-meter",
        "approval-card",
        "tool-call-card"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Moves into the chart once, onto one step; the rest are reached with the arrow keys"
        },
        {
          "keys": "ArrowRight / ArrowLeft",
          "action": "Next or previous step in the same lane (swapped in right-to-left)"
        },
        {
          "keys": "ArrowDown / ArrowUp",
          "action": "The closest step in the lane below or above"
        },
        {
          "keys": "Home / End",
          "action": "First or last step in the lane; with Ctrl, in the whole chart"
        },
        {
          "keys": "Enter / Space",
          "action": "Selects the step and shows its detail in a polite live region"
        },
        {
          "keys": "Screen readers",
          "action": "Each step reads its label, lane, kind and time range; handoffs are also listed as sentences"
        }
      ],
      "customization": [
        "duration and now",
        "formatTime for your own time text",
        "minTrackWidth before the chart scrolls sideways",
        "Controlled selection"
      ]
    },
    "statesNone": "A presentational chart: you pass the finished or running steps in, so there is nothing for it to load, submit or fail.",
    "examples": [
      "agent-lanes-demo",
      "agent-lanes-blocked"
    ],
    "url": "https://ui.ballmac.com/components/agent-lanes"
  },
  "categories": [
    "ai"
  ],
  "type": "registry:ui"
}