{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "run-log",
  "type": "registry:ui",
  "title": "Run Log",
  "description": "A streaming log grouped by step that follows output until you scroll away.",
  "categories": [
    "ai-agents"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/copy-button.json"
  ],
  "files": [
    {
      "path": "components/ui/run-log.tsx",
      "content": "\"use client\";\nimport { Collapsible } from \"@base-ui/react/collapsible\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useLayoutEffect, useRef, useState } from \"react\";\nimport { CopyButton } from \"@/components/ui/copy-button\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\nexport type LogStepStatus = \"pending\" | \"running\" | \"done\" | \"failed\" | \"skipped\";\ntype Time = number | Date;\nconst toMs = (t: Time) => (typeof t === \"number\" ? t : t.getTime());\n\nexport type LogLine = {\n  /** Stable, so appended lines are the only ones React touches. */\n  id: string;\n  time: Time;\n  level?: LogLevel;\n  text: string;\n};\n\nexport type LogStep = {\n  id: string;\n  label: React.ReactNode;\n  status: LogStepStatus;\n  lines: LogLine[];\n  /** Milliseconds, shown once the step has finished. */\n  duration?: number;\n};\n\nexport type RunLogProps = Omit<React.ComponentProps<\"div\">, \"title\" | \"children\"> & {\n  steps: LogStep[];\n  /** Names the log for screen readers and heads it when shown. */\n  title: React.ReactNode;\n  /** True while output is still arriving: shows Live, a caret on the newest line, and keeps following. */\n  streaming?: boolean;\n  /** Where +00:00.000 is. Defaults to the earliest line. */\n  origin?: Time;\n  /** Height of the scrolling area in pixels. */\n  height?: number;\n  /** Hide the title bar (title, live marker, count, copy). */\n  hideHeader?: boolean;\n};\n\nconst levelTag: Record<LogLevel, string> = { debug: \"dbg\", info: \"inf\", warn: \"wrn\", error: \"err\" };\n\n/** +MM:SS.mmm from the start of the run. Deterministic, so server and client agree. */\nexport function formatLogTime(ms: number) {\n  const t = Math.max(0, Math.round(ms));\n  const m = Math.floor(t / 60000);\n  const s = Math.floor(t / 1000) % 60;\n  return `+${String(m).padStart(2, \"0\")}:${String(s).padStart(2, \"0\")}.${String(t % 1000).padStart(3, \"0\")}`;\n}\n\n/** Plain text for copying: one line per entry, steps as headings. */\nexport function runLogText(steps: LogStep[], origin?: Time) {\n  const o = origin != null ? toMs(origin) : firstTime(steps);\n  return steps\n    .map((s) => [`## ${typeof s.label === \"string\" ? s.label : s.id} (${s.status})`, ...s.lines.map((l) => `${formatLogTime(toMs(l.time) - o)} ${levelTag[l.level ?? \"info\"].toUpperCase()} ${l.text}`)].join(\"\\n\"))\n    .join(\"\\n\\n\");\n}\n\nconst firstTime = (steps: LogStep[]) => {\n  let min = Infinity;\n  for (const s of steps) for (const l of s.lines) min = Math.min(min, toMs(l.time));\n  return Number.isFinite(min) ? min : 0;\n};\n\n/**\n * A streaming run log, grouped by step. It follows new output while you're at\n * the bottom; scroll up and it stops, counting what arrives until you jump back.\n */\nexport function RunLog({ steps, title, streaming = false, origin, height = 320, hideHeader = false, className, ...rest }: RunLogProps) {\n  const reduce = useReducedMotion();\n  const scroller = useRef<HTMLDivElement>(null);\n  const total = steps.reduce((n, s) => n + s.lines.length, 0);\n  const o = origin != null ? toMs(origin) : firstTime(steps);\n\n  // Following: on while the reader is at the bottom. Scrolling up turns it off\n  // and remembers how much had arrived, so the pill can say how much is new.\n  const [following, setFollowing] = useState(true);\n  const [seen, setSeen] = useState(0);\n  const unseen = following ? 0 : Math.max(0, total - seen);\n\n  // A smooth jump passes through \"not at the bottom\"; don't mistake it for the reader leaving.\n  const jumping = useRef(false);\n  const onScroll = () => {\n    const el = scroller.current;\n    if (!el) return;\n    const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24;\n    if (jumping.current) {\n      if (atBottom) jumping.current = false;\n      return;\n    }\n    if (atBottom !== following) {\n      setFollowing(atBottom);\n      if (!atBottom) setSeen(total);\n    }\n  };\n\n  // Before paint, so a following log never shows a frame with the new line below the fold.\n  useLayoutEffect(() => {\n    const el = scroller.current;\n    if (el && following) el.scrollTop = el.scrollHeight;\n  }, [total, following, steps]);\n\n  const jump = useCallback(() => {\n    const el = scroller.current;\n    if (!el) return;\n    jumping.current = !reduce;\n    el.scrollTo({ top: el.scrollHeight, behavior: reduce ? \"auto\" : \"smooth\" });\n    setFollowing(true);\n    el.focus({ preventScroll: true });\n  }, [reduce]);\n\n  // Announce steps as they change state, never individual lines.\n  const latest = [...steps].reverse().find((s) => s.status !== \"pending\");\n  const announcement = latest && typeof latest.label === \"string\" ? `${latest.label}: ${latest.status === \"running\" ? \"running\" : latest.status}` : \"\";\n\n  return (\n    <div className={cn(\"@container/log relative flex min-w-0 flex-col overflow-hidden rounded-xl border border-line bg-raised shadow-[var(--shadow)]\", className)} {...rest}>\n      {!hideHeader && (\n        <div className=\"flex h-10 shrink-0 items-center gap-2.5 border-b border-line pl-3 pr-1.5\">\n          <span className=\"min-w-0 truncate text-[13px] font-medium tracking-[-0.006em] text-fg\">{title}</span>\n          <AnimatePresence initial={false}>\n            {streaming && (\n              <motion.span\n                key=\"live\"\n                className=\"inline-flex shrink-0 items-center gap-1.5 rounded-full bg-success-soft px-1.5 py-px text-[11px] font-medium text-success\"\n                initial={{ opacity: 0, scale: reduce ? 1 : 0.9 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={{ opacity: 0, transition: { duration: 0.15 } }}\n                transition={reduce ? { duration: 0.15 } : spring.pop}\n              >\n                <span className=\"relative grid size-1.5 place-items-center\">\n                  <span className=\"absolute size-1.5 animate-ping-soft rounded-full bg-success\" />\n                  <span className=\"relative size-1.5 rounded-full bg-success\" />\n                </span>\n                Live\n              </motion.span>\n            )}\n          </AnimatePresence>\n          <span className=\"ml-auto shrink-0 text-[12px] tabular text-fg-3\">\n            <NumberFlow value={total} locales=\"en-US\" /> {total === 1 ? \"line\" : \"lines\"}\n          </span>\n          <CopyButton value={() => runLogText(steps, origin)} iconOnly variant=\"ghost\" size=\"sm\" label=\"Copy log\" copiedLabel=\"Log copied\" />\n        </div>\n      )}\n\n      <div\n        ref={scroller}\n        role=\"log\"\n        aria-live=\"off\"\n        aria-label={typeof title === \"string\" ? title : \"Run log\"}\n        tabIndex={0}\n        onScroll={onScroll}\n        style={{ height }}\n        className={cn(\n          \"relative overflow-y-auto overflow-x-hidden overscroll-contain font-mono text-[12px] leading-5 outline-none\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n        )}\n      >\n        {steps.length === 0 ? (\n          <p className=\"px-3 py-3 font-sans text-[12.5px] text-fg-3\">{streaming ? \"Waiting for output…\" : \"No output\"}</p>\n        ) : (\n          steps.map((step, i) => <Step key={step.id} step={step} origin={o} streaming={streaming} last={i === steps.length - 1} />)\n        )}\n      </div>\n\n      {/* Anchored to the bottom edge; only there while the reader has scrolled away from new output. */}\n      <AnimatePresence>\n        {!following && (\n          <motion.button\n            key=\"jump\"\n            type=\"button\"\n            onClick={jump}\n            className={cn(\n              \"absolute bottom-3 left-1/2 z-[2] inline-flex h-7 items-center gap-1.5 rounded-full border border-line-2 bg-raised pl-2 pr-2.5 text-[12px] font-medium text-fg shadow-pop\",\n              \"outline-none transition-[background-color] duration-150 hover:bg-hover focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 active:scale-[0.97]\",\n              \"pointer-coarse:before:absolute pointer-coarse:before:-inset-2 pointer-coarse:before:content-['']\",\n            )}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.96 }}\n            animate={{ opacity: 1, y: 0, scale: 1 }}\n            exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, y: 6, scale: 0.97, transition: { duration: 0.14, ease: ease.in } }}\n            transition={reduce ? { duration: 0.15 } : spring.snappy}\n            style={{ x: \"-50%\" }}\n          >\n            <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className=\"text-fg-2\">\n              <path d=\"M8 3v10M4 9l4 4 4-4\" />\n            </svg>\n            {unseen > 0 ? (\n              <span className=\"tabular\">\n                <NumberFlow value={unseen} locales=\"en-US\" /> new {unseen === 1 ? \"line\" : \"lines\"}\n              </span>\n            ) : (\n              \"Jump to latest\"\n            )}\n          </motion.button>\n        )}\n      </AnimatePresence>\n\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n\nfunction Step({ step, origin, streaming, last }: { step: LogStep; origin: number; streaming: boolean; last: boolean }) {\n  const { status, lines } = step;\n  // Steps follow the run (open while running or failed, folded when done) until\n  // the reader toggles one; from then on their choice stands.\n  const [override, setOverride] = useState<boolean>();\n  const auto = status === \"running\" || status === \"failed\" || (last && status === \"done\" && !streaming);\n  const open = lines.length > 0 && (override ?? auto);\n  const warnings = lines.filter((l) => l.level === \"warn\").length;\n  const errors = lines.filter((l) => l.level === \"error\").length;\n\n  return (\n    <Collapsible.Root open={open} onOpenChange={(next) => setOverride(next)} disabled={lines.length === 0} data-status={status}>\n      <Collapsible.Trigger\n        className={cn(\n          \"group/step sticky top-0 z-[1] flex h-8 w-full items-center gap-2 border-b border-line bg-raised pl-2 pr-3 text-left font-sans\",\n          \"outline-none transition-[background-color] duration-150 hover:bg-hover active:bg-fg/[0.06] focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n          \"data-[disabled]:cursor-default data-[disabled]:hover:bg-raised\",\n        )}\n      >\n        <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className={cn(\"shrink-0 text-fg-4 transition-[rotate,color] duration-200 ease-in-out-quart group-data-[panel-open]/step:rotate-90 group-hover/step:text-fg-3 motion-reduce:transition-none\", lines.length === 0 && \"invisible\")}>\n          <path d=\"m6.25 4.5 3.5 3.5-3.5 3.5\" />\n        </svg>\n        <StepGlyph status={status} />\n        <span className={cn(\"min-w-0 flex-1 truncate text-[12.5px] font-medium\", status === \"pending\" || status === \"skipped\" ? \"text-fg-3\" : \"text-fg\")}>{step.label}</span>\n        {errors > 0 && <span className=\"shrink-0 rounded px-1 text-[11px] tabular text-danger bg-danger-soft\">{errors} {errors === 1 ? \"error\" : \"errors\"}</span>}\n        {warnings > 0 && <span className=\"shrink-0 rounded px-1 text-[11px] tabular text-warning bg-warning-soft @max-[24rem]/log:hidden\">{warnings} {warnings === 1 ? \"warning\" : \"warnings\"}</span>}\n        <span className=\"shrink-0 font-mono text-[11px] tabular text-fg-3\">{step.duration != null && status !== \"running\" ? formatDuration(step.duration) : status === \"pending\" ? \"Queued\" : status === \"skipped\" ? \"Skipped\" : null}</span>\n        <span className=\"sr-only\">, {status}</span>\n      </Collapsible.Trigger>\n      <Collapsible.Panel className=\"h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-200 ease-out-quart data-starting-style:h-0 data-ending-style:h-0 motion-reduce:transition-none\">\n        <ol className=\"py-1\">\n          {lines.map((line, i) => (\n            <Line key={line.id} line={line} origin={origin} caret={streaming && status === \"running\" && i === lines.length - 1} />\n          ))}\n        </ol>\n      </Collapsible.Panel>\n    </Collapsible.Root>\n  );\n}\n\n// New lines fade in from their starting style: cheap, CSS only, and nothing moves.\nfunction Line({ line, origin, caret }: { line: LogLine; origin: number; caret: boolean }) {\n  const level = line.level ?? \"info\";\n  return (\n    <li\n      data-level={level}\n      className={cn(\n        \"grid grid-cols-[auto_auto_minmax(0,1fr)] gap-x-3 px-3 [contain-intrinsic-size:auto_20px] [content-visibility:auto]\",\n        \"transition-opacity duration-150 ease-out starting:opacity-0\",\n        level === \"error\" && \"bg-danger-soft\",\n        level === \"warn\" && \"bg-warning-soft/60\",\n      )}\n    >\n      <span className=\"select-none tabular text-fg-3 @max-[24rem]/log:hidden\">{formatLogTime(toMs(line.time) - origin)}</span>\n      <span className={cn(\"select-none uppercase\", level === \"error\" ? \"text-danger\" : level === \"warn\" ? \"text-warning\" : \"text-fg-4\")}>{levelTag[level]}</span>\n      <span className={cn(\"whitespace-pre-wrap [overflow-wrap:anywhere]\", level === \"error\" ? \"text-danger\" : level === \"debug\" ? \"text-fg-3\" : \"text-fg-2\")}>\n        {line.text}\n        {caret && <span aria-hidden className=\"ml-0.5 inline-block h-3.5 w-[7px] translate-y-[2px] animate-caret bg-fg-3\" />}\n      </span>\n    </li>\n  );\n}\n\nfunction formatDuration(ms: number) {\n  if (ms < 1000) return `${Math.round(ms)}ms`;\n  const s = ms / 1000;\n  if (s < 60) return `${s < 10 ? s.toFixed(1) : Math.round(s)}s`;\n  return `${Math.floor(s / 60)}m ${String(Math.floor(s % 60)).padStart(2, \"0\")}s`;\n}\n\nfunction StepGlyph({ status }: { status: LogStepStatus }) {\n  const reduce = useReducedMotion();\n  return (\n    <span className=\"relative grid size-3.5 shrink-0 place-items-center\">\n      <AnimatePresence initial={false}>\n        <motion.span\n          key={status}\n          className=\"absolute inset-0 grid place-items-center\"\n          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n          animate={{ opacity: 1, scale: 1 }}\n          exit={{ opacity: 0, transition: { duration: 0.1 } }}\n          transition={reduce ? { duration: 0.15 } : spring.pop}\n        >\n          {status === \"running\" ? (\n            <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden className=\"size-3.5 animate-spin [animation-duration:0.9s]\">\n              <circle cx=\"8\" cy=\"8\" r=\"6\" stroke=\"var(--line-2)\" strokeWidth=\"2\" />\n              <path d=\"M8 2a6 6 0 0 1 6 6\" stroke=\"var(--fg)\" strokeWidth=\"2\" strokeLinecap=\"round\" />\n            </svg>\n          ) : status === \"done\" ? (\n            <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.8} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className=\"text-success\">\n              <motion.path d=\"M3 8.5 6.5 12 13 4.5\" initial={{ pathLength: reduce ? 1 : 0 }} animate={{ pathLength: 1 }} transition={reduce ? { duration: 0 } : { duration: 0.3, ease: ease.out, delay: 0.06 }} />\n            </svg>\n          ) : status === \"failed\" ? (\n            <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.8} strokeLinecap=\"round\" aria-hidden className=\"text-danger\">\n              <path d=\"m4 4 8 8M12 4l-8 8\" />\n            </svg>\n          ) : status === \"skipped\" ? (\n            <span className=\"size-2.5 rounded-full border border-dashed border-fg-4\" />\n          ) : (\n            <span className=\"size-2.5 rounded-full border-[1.5px] border-line-2\" />\n          )}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/run-log"
}
