{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"agent-run-timeline","type":"registry:ui","title":"Agent Run Timeline","description":"The activity log for a long agent run — collapsible success clusters, retry-linked events, approval gates in place, and a follow that respects the reader.","author":"Scrim UI (https://scrimui.dev)","categories":["agents"],"docs":"https://scrimui.dev/components/agent-run-timeline","dependencies":[],"registryDependencies":[],"files":[{"path":"src/showcase/agent-run-timeline/agent-run-timeline.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/**\n * The long-run activity log for an agent — tens to hundreds of events.\n *\n * The rules this component holds:\n *\n * **Success collapses, trouble expands.** A healthy run is boring; the\n * reader is here for the blocked approval and the failed tool call.\n * Consecutive completed steps fold into a countable cluster — openable,\n * never deleted — while waiting, running, failed and cancelled events are\n * always visible.\n *\n * **A retry never overwrites its original.** The failed attempt stays in\n * the log with its error; the retry lands as a new event linked to it.\n * A log that silently rewrites history is a dashboard, not a record.\n *\n * **The list follows only a reader who's already at the bottom.** New\n * events append without yanking someone reading step 12 of 80 — a \"back\n * to latest\" pill carries them down when they choose.\n *\n * **Approvals keep their visual rank.** An approval gate is the only event\n * that can spend money or touch production, so it never collapses and it\n * never looks like a tool call.\n */\n\n/* ------------------------------------------------------------------ */\n/* Types                                                               */\n/* ------------------------------------------------------------------ */\n\nexport type RunEventKind = \"model\" | \"tool\" | \"approval\" | \"handoff\" | \"error\" | \"note\";\n\nexport type RunEventStatus = \"running\" | \"waiting\" | \"completed\" | \"failed\" | \"cancelled\";\n\nexport type RunEvent = {\n  /** Stable identity — never an array index. */\n  id: string;\n  kind: RunEventKind;\n  title: string;\n  detail?: string;\n  /** Display timestamp, e.g. \"14:02:11\". */\n  at: string;\n  status: RunEventStatus;\n  durationMs?: number;\n  /** The event this one retries. The original stays in the log, untouched. */\n  retryOf?: string;\n};\n\nexport type RunSummary = {\n  tokens?: number;\n  cost?: string;\n  elapsed?: string;\n};\n\nexport type AgentRunTimelineProps = {\n  events: RunEvent[];\n  onApprove?: (id: string) => void;\n  onReject?: (id: string) => void;\n  summary?: RunSummary;\n  emptyText?: string;\n  className?: string;\n};\n\n/* ------------------------------------------------------------------ */\n/* Icons                                                               */\n/* ------------------------------------------------------------------ */\n\nconst ICON_PROPS = {\n  viewBox: \"0 0 24 24\",\n  fill: \"none\",\n  stroke: \"currentColor\",\n  strokeWidth: 2,\n  strokeLinecap: \"round\",\n  strokeLinejoin: \"round\",\n} as const;\n\nfunction ModelIcon() {\n  return (\n    <svg {...ICON_PROPS} width=\"13\" height=\"13\">\n      <path d=\"M12 8V4H8\" />\n      <rect width=\"16\" height=\"12\" x=\"4\" y=\"8\" rx=\"2\" />\n      <path d=\"M2 14h2\" />\n      <path d=\"M20 14h2\" />\n      <path d=\"M15 13v2\" />\n      <path d=\"M9 13v2\" />\n    </svg>\n  );\n}\n\nfunction ToolIcon() {\n  return (\n    <svg {...ICON_PROPS} width=\"13\" height=\"13\">\n      <path d=\"M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z\" />\n    </svg>\n  );\n}\n\nfunction ApprovalIcon() {\n  return (\n    <svg {...ICON_PROPS} width=\"13\" height=\"13\">\n      <path d=\"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z\" />\n      <path d=\"m9 12 2 2 4-4\" />\n    </svg>\n  );\n}\n\nfunction HandoffIcon() {\n  return (\n    <svg {...ICON_PROPS} width=\"13\" height=\"13\">\n      <path d=\"m17 3 4 4-4 4\" />\n      <path d=\"M21 7H9\" />\n      <path d=\"m7 21-4-4 4-4\" />\n      <path d=\"M3 17h12\" />\n    </svg>\n  );\n}\n\nfunction ErrorIcon() {\n  return (\n    <svg {...ICON_PROPS} width=\"13\" height=\"13\">\n      <circle cx=\"12\" cy=\"12\" r=\"10\" />\n      <line x1=\"12\" x2=\"12\" y1=\"8\" y2=\"12\" />\n      <line x1=\"12.01\" x2=\"12\" y1=\"16\" y2=\"16\" />\n    </svg>\n  );\n}\n\nfunction NoteIcon() {\n  return (\n    <svg {...ICON_PROPS} width=\"13\" height=\"13\">\n      <path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z\" />\n    </svg>\n  );\n}\n\nconst KIND_ICONS: Record<RunEventKind, React.ReactNode> = {\n  model: <ModelIcon />,\n  tool: <ToolIcon />,\n  approval: <ApprovalIcon />,\n  handoff: <HandoffIcon />,\n  error: <ErrorIcon />,\n  note: <NoteIcon />,\n};\n\n/* ------------------------------------------------------------------ */\n/* Clustering — consecutive completed steps fold into one row          */\n/* ------------------------------------------------------------------ */\n\ntype Row = { type: \"event\"; event: RunEvent } | { type: \"cluster\"; key: string; events: RunEvent[] };\n\nconst COLLAPSIBLE_MIN = 3;\n\nfunction isCollapsible(e: RunEvent) {\n  return e.status === \"completed\" && e.kind !== \"approval\";\n}\n\nfunction buildRows(events: RunEvent[]): Row[] {\n  const rows: Row[] = [];\n  let cluster: RunEvent[] = [];\n  const flush = () => {\n    if (cluster.length >= COLLAPSIBLE_MIN) {\n      rows.push({ type: \"cluster\", key: `c-${cluster[0].id}`, events: cluster });\n    } else {\n      cluster.forEach((e) => rows.push({ type: \"event\", event: e }));\n    }\n    cluster = [];\n  };\n  events.forEach((e) => {\n    if (isCollapsible(e)) {\n      cluster.push(e);\n    } else {\n      flush();\n      rows.push({ type: \"event\", event: e });\n    }\n  });\n  flush();\n  return rows;\n}\n\nfunction formatDuration(ms: number): string {\n  if (ms < 1000) return `${ms}ms`;\n  return `${(ms / 1000).toFixed(1)}s`;\n}\n\n/* ------------------------------------------------------------------ */\n/* AgentRunTimeline                                                    */\n/* ------------------------------------------------------------------ */\n\nexport function AgentRunTimeline({\n  events,\n  onApprove,\n  onReject,\n  summary,\n  emptyText = \"No events yet — the run's activity will appear here.\",\n  className = \"\",\n}: AgentRunTimelineProps) {\n  const scrollRef = React.useRef<HTMLDivElement>(null);\n  const atBottomRef = React.useRef(true);\n  const [atBottom, setAtBottom] = React.useState(true);\n  const [expanded, setExpanded] = React.useState<Set<string>>(new Set());\n\n  const rows = buildRows(events);\n  const activeId = events.find((e) => e.status === \"running\" || e.status === \"waiting\")?.id;\n\n  /* Auto-follow: only while the reader is already at the bottom. The state\n     writes happen in the scroll/effect callbacks, never in render. */\n  React.useEffect(() => {\n    const el = scrollRef.current;\n    if (el && atBottomRef.current) el.scrollTop = el.scrollHeight;\n  }, [events.length]);\n\n  function onScroll() {\n    const el = scrollRef.current;\n    if (!el) return;\n    const at = el.scrollHeight - el.scrollTop - el.clientHeight < 24;\n    atBottomRef.current = at;\n    setAtBottom(at);\n  }\n\n  function jumpToLatest() {\n    const el = scrollRef.current;\n    if (el) el.scrollTop = el.scrollHeight;\n  }\n\n  function toggleCluster(key: string) {\n    setExpanded((s) => {\n      const next = new Set(s);\n      if (next.has(key)) next.delete(key);\n      else next.add(key);\n      return next;\n    });\n  }\n\n  const retryTitles = new Map(events.map((e) => [e.id, e.title]));\n\n  function renderEvent(e: RunEvent) {\n    const highlight =\n      e.status === \"failed\" || e.status === \"cancelled\"\n        ? \"border-l-2 border-red-400 dark:border-red-500\"\n        : e.kind === \"approval\" && e.status === \"waiting\"\n          ? \"border-l-2 border-amber-400 dark:border-amber-500\"\n          : e.status === \"running\"\n            ? \"border-l-2 border-blue-400 dark:border-blue-500\"\n            : \"border-l-2 border-transparent\";\n    return (\n      <li key={e.id} data-event-id={e.id} className={`flex flex-wrap items-baseline gap-x-2.5 py-1.5 pl-2 pr-3 ${highlight}`}>\n        <span className=\"w-14 shrink-0 text-[11px] tabular-nums text-zinc-400 dark:text-zinc-500\">{e.at}</span>\n        <span className={`shrink-0 self-center ${e.status === \"failed\" ? \"text-red-500\" : \"text-zinc-400 dark:text-zinc-500\"}`}>\n          {KIND_ICONS[e.kind]}\n        </span>\n        <span className=\"min-w-0 flex-1\">\n          <span className=\"text-[13px] font-medium text-zinc-800 dark:text-zinc-100\">{e.title}</span>\n          {e.retryOf && (\n            <span className=\"ml-1.5 rounded bg-zinc-100 px-1.5 py-0.5 text-[10px] font-medium text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400\">\n              retry of “{retryTitles.get(e.retryOf) ?? e.retryOf}”\n            </span>\n          )}\n          {e.detail && (\n            <span className=\"block truncate text-xs text-zinc-500 dark:text-zinc-400\">{e.detail}</span>\n          )}\n        </span>\n        <span className=\"flex shrink-0 items-center gap-2 self-center\">\n          {e.durationMs != null && (\n            <span className=\"text-[11px] tabular-nums text-zinc-400 dark:text-zinc-500\">{formatDuration(e.durationMs)}</span>\n          )}\n          {e.status === \"running\" && (\n            <span className=\"h-2 w-2 animate-pulse rounded-full bg-blue-500\" aria-label=\"Running\" />\n          )}\n          {e.status === \"waiting\" && e.kind !== \"approval\" && (\n            <span className=\"text-[11px] font-medium text-amber-600 dark:text-amber-400\">Waiting</span>\n          )}\n          {e.status === \"failed\" && (\n            <span className=\"text-[11px] font-medium text-red-600 dark:text-red-400\">Failed</span>\n          )}\n          {e.status === \"cancelled\" && (\n            <span className=\"text-[11px] font-medium text-zinc-400 dark:text-zinc-500\">Cancelled</span>\n          )}\n        </span>\n        {e.kind === \"approval\" && e.status === \"waiting\" && (onApprove || onReject) && (\n          <span className=\"flex w-full gap-2 pl-[4.75rem] pt-1\">\n            {onApprove && (\n              <button\n                type=\"button\"\n                onClick={() => onApprove(e.id)}\n                className=\"rounded-md bg-zinc-900 px-2.5 py-1 text-[11px] font-medium text-white hover:bg-zinc-700 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300\"\n              >\n                Approve\n              </button>\n            )}\n            {onReject && (\n              <button\n                type=\"button\"\n                onClick={() => onReject(e.id)}\n                className=\"rounded-md border border-zinc-200 px-2.5 py-1 text-[11px] font-medium text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800\"\n              >\n                Reject\n              </button>\n            )}\n          </span>\n        )}\n      </li>\n    );\n  }\n\n  return (\n    <div className={`relative flex flex-col overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900 ${className}`}>\n      <div\n        ref={scrollRef}\n        onScroll={onScroll}\n        className=\"min-h-0 flex-1 overflow-y-auto py-2\"\n        role=\"log\"\n        aria-label=\"Agent run activity\"\n      >\n        {events.length === 0 ? (\n          <p className=\"px-4 py-8 text-center text-xs text-zinc-500 dark:text-zinc-400\">{emptyText}</p>\n        ) : (\n          <ul>\n            {rows.map((row) =>\n              row.type === \"event\" ? (\n                renderEvent(row.event)\n              ) : expanded.has(row.key) ? (\n                <React.Fragment key={row.key}>\n                  <li className=\"py-1 pl-2\">\n                    <button\n                      type=\"button\"\n                      onClick={() => toggleCluster(row.key)}\n                      aria-expanded=\"true\"\n                      className=\"rounded-md bg-zinc-100 px-2 py-1 text-[11px] font-medium text-zinc-500 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700\"\n                    >\n                      Hide {row.events.length} completed steps\n                    </button>\n                  </li>\n                  {row.events.map(renderEvent)}\n                </React.Fragment>\n              ) : (\n                <li key={row.key} className=\"py-1 pl-2\">\n                  <button\n                    type=\"button\"\n                    onClick={() => toggleCluster(row.key)}\n                    aria-expanded=\"false\"\n                    className=\"rounded-md bg-zinc-100 px-2 py-1 text-[11px] font-medium text-zinc-500 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700\"\n                  >\n                    {row.events.length} completed steps · {row.events[0].at}–{row.events[row.events.length - 1].at}\n                  </button>\n                </li>\n              ),\n            )}\n          </ul>\n        )}\n      </div>\n\n      {!atBottom && (\n        <button\n          type=\"button\"\n          onClick={jumpToLatest}\n          className=\"absolute bottom-3 right-3 rounded-full bg-zinc-900 px-3 py-1.5 text-[11px] font-medium text-white shadow-lg hover:bg-zinc-700 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300\"\n        >\n          ↓ {activeId ? \"Back to active step\" : \"Back to latest\"}\n        </button>\n      )}\n\n      {summary && (summary.tokens != null || summary.cost || summary.elapsed) && (\n        <div className=\"flex flex-wrap gap-x-4 border-t border-zinc-100 px-3 py-2 text-[11px] tabular-nums text-zinc-500 dark:border-zinc-800/60 dark:text-zinc-400\">\n          {summary.elapsed && <span>Elapsed {summary.elapsed}</span>}\n          {summary.tokens != null && <span>{summary.tokens.toLocaleString()} tokens</span>}\n          {summary.cost && <span>{summary.cost}</span>}\n        </div>\n      )}\n    </div>\n  );\n}\n","type":"registry:ui","target":"components/ui/agent-run-timeline.tsx"}]}