{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "run-replay",
  "title": "Run Replay",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Transport controls for replaying a recorded agent run: play and pause, a scrubber with marker dots, step to the previous or next marker, and 0.5 to 4 times speed. It owns the clock so one control can drive a trace, lanes or a log.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/run-replay.tsx",
      "content": "// Ballmac UI: Run Replay. https://ui.ballmac.com/components/run-replay\n\"use client\"\n\nimport * as React from \"react\"\nimport { Pause, Play, SkipBack, SkipForward } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype ReplayMarker = {\n  /** Where it happens, in seconds. */\n  time: number\n  /** What happens, for example \"Tool call failed\". */\n  label: string\n}\n\nconst SPEEDS = [0.5, 1, 2, 4] as const\n/** How often playback advances, in milliseconds. Fixed steps keep it exact and testable. */\nconst TICK = 100\n\nfunction clock(seconds: number) {\n  const s = Math.max(0, Math.floor(seconds))\n  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, \"0\")}`\n}\n\ntype RunReplayProps = Omit<React.ComponentProps<\"div\">, \"onChange\" | \"defaultValue\"> & {\n  /** Length of the run in seconds. */\n  duration: number\n  /** Where playback is, in seconds. Controlled. */\n  time?: number\n  /** Where playback starts, for uncontrolled use. */\n  defaultTime?: number\n  /** Called as the time changes, from playing or from the person. */\n  onTimeChange?: (time: number) => void\n  /** Whether it is playing. Controlled. */\n  playing?: boolean\n  /** Whether it starts playing. Playback never starts by itself unless you ask. */\n  defaultPlaying?: boolean\n  /** Called when play or pause changes. */\n  onPlayingChange?: (playing: boolean) => void\n  /** Points of interest to jump between: a failure, a handoff, an approval. */\n  markers?: ReplayMarker[]\n  /** Speeds to offer. */\n  speeds?: readonly number[]\n  /** Turns seconds into text. Defaults to m:ss. */\n  formatTime?: (seconds: number) => string\n}\n\n/**\n * Transport controls for replaying a recorded agent run: play and pause, step between markers, a scrubber and a speed. It owns only the clock; you\n * render the run (a trace, lanes, a log) from `time`, so one control drives whatever the page shows.\n */\nfunction RunReplay({\n  duration,\n  time: timeProp,\n  defaultTime = 0,\n  onTimeChange,\n  playing: playingProp,\n  defaultPlaying = false,\n  onPlayingChange,\n  markers = [],\n  speeds = SPEEDS,\n  formatTime = clock,\n  className,\n  ...props\n}: RunReplayProps) {\n  const msg = useMessages()\n  const [innerTime, setInnerTime] = React.useState(defaultTime)\n  const [innerPlaying, setInnerPlaying] = React.useState(defaultPlaying)\n  const [speed, setSpeed] = React.useState<number>(speeds.includes(1) ? 1 : speeds[0])\n  const time = Math.min(Math.max(timeProp ?? innerTime, 0), duration)\n  const playing = (playingProp ?? innerPlaying) && time < duration\n  const sorted = React.useMemo(() => [...markers].sort((a, b) => a.time - b.time), [markers])\n  const [announced, setAnnounced] = React.useState(\"\")\n\n  // The latest values, read by the interval so it never needs to restart while playing. The interval also keeps its own running clock, so several ticks\n  // in a row add up even before React has re-rendered; a seek from outside (a new `time`) resets it.\n  const live = React.useRef({ time, speed, duration, onTimeChange, timeProp, sorted })\n  const clock = React.useRef(time)\n  React.useEffect(() => {\n    live.current = { time, speed, duration, onTimeChange, timeProp, sorted }\n    if (Math.abs(clock.current - time) > 1e-6) clock.current = time\n  })\n\n  function setTime(next: number) {\n    const t = Math.min(Math.max(next, 0), duration)\n    if (timeProp === undefined) setInnerTime(t)\n    onTimeChange?.(t)\n  }\n  function setPlaying(next: boolean) {\n    if (playingProp === undefined) setInnerPlaying(next)\n    onPlayingChange?.(next)\n  }\n\n  React.useEffect(() => {\n    if (!playing) return\n    const id = setInterval(() => {\n      const { speed: sp, duration: d, onTimeChange: cb, timeProp: controlled, sorted: ms } = live.current\n      const t = clock.current\n      const next = Math.min(d, +(t + (TICK / 1000) * sp).toFixed(3))\n      clock.current = next\n      const crossed = ms.find((m) => m.time > t && m.time <= next)\n      if (crossed) setAnnounced(crossed.label)\n      if (controlled === undefined) setInnerTime(next)\n      cb?.(next)\n      if (next >= d) {\n        if (playingProp === undefined) setInnerPlaying(false)\n        onPlayingChange?.(false)\n      }\n    }, TICK)\n    return () => clearInterval(id)\n  }, [playing, playingProp, onPlayingChange])\n\n  const prev = [...sorted].reverse().find((m) => m.time < time - 0.5)\n  const next = sorted.find((m) => m.time > time + 0.001)\n  const at = duration > 0 ? (time / duration) * 100 : 0\n  const btn = \"inline-flex size-9 items-center justify-center rounded-full border bg-background text-foreground outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50\"\n\n  return (\n    <div data-slot=\"run-replay\" data-playing={playing || undefined} className={cn(\"grid w-full gap-3 rounded-xl border bg-card p-4 text-card-foreground\", className)} {...props}>\n      <div className=\"flex flex-wrap items-center gap-2\">\n        <button type=\"button\" disabled={!prev} onClick={() => prev && setTime(prev.time)} aria-label={msg(\"run-replay.prev\", \"Previous marker\")} className={btn}>\n          <SkipBack aria-hidden=\"true\" className=\"size-4 rtl:-scale-x-100\" />\n        </button>\n        <button\n          type=\"button\"\n          onClick={() => {\n            if (!playing && time >= duration) setTime(0)\n            setPlaying(!playing)\n          }}\n          aria-label={playing ? msg(\"run-replay.pause\", \"Pause\") : time >= duration ? msg(\"run-replay.replay\", \"Replay from the start\") : msg(\"run-replay.play\", \"Play\")}\n          className={cn(btn, \"size-10 bg-primary text-primary-foreground hover:bg-primary/90\")}\n        >\n          {playing ? <Pause aria-hidden=\"true\" className=\"size-4\" /> : <Play aria-hidden=\"true\" className=\"size-4 rtl:-scale-x-100\" />}\n        </button>\n        <button type=\"button\" disabled={!next} onClick={() => next && setTime(next.time)} aria-label={msg(\"run-replay.next\", \"Next marker\")} className={btn}>\n          <SkipForward aria-hidden=\"true\" className=\"size-4 rtl:-scale-x-100\" />\n        </button>\n        <p className=\"ms-2 font-mono text-sm tabular-nums\" dir=\"ltr\">\n          <span className=\"text-foreground\">{formatTime(time)}</span>\n          <span className=\"text-muted-foreground\"> / {formatTime(duration)}</span>\n        </p>\n        <div role=\"group\" aria-label={msg(\"run-replay.speed\", \"Playback speed\")} className=\"ms-auto flex gap-1\">\n          {speeds.map((s) => (\n            <button\n              key={s}\n              type=\"button\"\n              aria-pressed={speed === s}\n              onClick={() => setSpeed(s)}\n              className={cn(\"h-8 rounded-full px-2.5 font-mono text-xs outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50\", speed === s ? \"bg-foreground text-background\" : \"text-muted-foreground hover:bg-accent hover:text-foreground\")}\n            >\n              {s}×\n            </button>\n          ))}\n        </div>\n      </div>\n\n      <div className=\"relative\">\n        <input\n          type=\"range\"\n          min={0}\n          max={duration}\n          step={1}\n          value={Math.round(time)}\n          onChange={(e) => setTime(Number(e.target.value))}\n          aria-label={msg(\"run-replay.scrub\", \"Position in the run\")}\n          aria-valuetext={`${formatTime(time)} of ${formatTime(duration)}`}\n          className=\"peer relative z-10 h-6 w-full cursor-pointer appearance-none bg-transparent outline-none [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-foreground [&::-moz-range-thumb]:bg-background [&::-moz-range-track]:bg-transparent [&::-webkit-slider-runnable-track]:bg-transparent [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-foreground [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm focus-visible:[&::-webkit-slider-thumb]:ring-[3px] focus-visible:[&::-webkit-slider-thumb]:ring-ring/50\"\n        />\n        <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-muted\">\n          <div className=\"h-full rounded-full bg-foreground\" style={{ width: `${at}%` }} />\n          {sorted.map((m) => (\n            <span key={`${m.time}-${m.label}`} className=\"absolute top-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-card bg-chart-3 rtl:translate-x-1/2\" style={{ insetInlineStart: `${duration > 0 ? (m.time / duration) * 100 : 0}%` }} />\n          ))}\n        </div>\n      </div>\n\n      {sorted.length > 0 && (\n        <ul className=\"flex flex-wrap gap-1.5\" aria-label={msg(\"run-replay.markers\", \"Markers\")}>\n          {sorted.map((m) => (\n            <li key={`${m.time}-${m.label}`}>\n              <button\n                type=\"button\"\n                onClick={() => setTime(m.time)}\n                className={cn(\"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50\", Math.abs(m.time - time) < 0.5 && \"border-foreground bg-accent\")}\n              >\n                <span className=\"font-mono text-muted-foreground tabular-nums\" dir=\"ltr\">{formatTime(m.time)}</span>\n                {m.label}\n              </button>\n            </li>\n          ))}\n        </ul>\n      )}\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {announced && msg(\"run-replay.reached\", \"Reached: {label}\", { label: announced })}\n      </p>\n    </div>\n  )\n}\n\nexport { RunReplay, type ReplayMarker, type RunReplayProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/run-replay.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "agents",
      "replay",
      "scrubber",
      "playback",
      "timeline",
      "debug"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "duration in seconds; time / defaultTime / onTimeChange; playing / defaultPlaying / onPlayingChange; markers [{ time, label }]; speeds (0.5, 1, 2, 4). It only keeps the clock and renders controls; read `time` to show the run. Playback never starts by itself.",
      "whenToUse": [
        "Debugging a finished agent run by watching it unfold",
        "Letting people scrub a recorded session next to its trace or logs"
      ],
      "whenNotToUse": [
        "Playing audio or video; use video-dialog or audio-player",
        "A live run; use trace or agent-lanes with now"
      ],
      "composesWith": [
        "agent-lanes",
        "trace",
        "log-stream",
        "run-ledger"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Previous marker, Play or Pause, Next marker, the speeds, the scrubber, then each marker"
        },
        {
          "keys": "Space / Enter",
          "action": "Toggles play and pause"
        },
        {
          "keys": "ArrowRight / ArrowLeft on the scrubber",
          "action": "Moves one second (native range input); Home and End go to the ends"
        },
        {
          "keys": "Screen readers",
          "action": "The scrubber reads '1:12 of 4:30'; a polite note says which marker playback just reached"
        }
      ],
      "customization": [
        "speeds",
        "formatTime",
        "markers",
        "Controlled time and playing"
      ]
    },
    "statesNone": "A control with no data of its own: it keeps a clock and reports it, so there is nothing to load or fail.",
    "examples": [
      "run-replay-demo",
      "run-replay-lanes"
    ],
    "url": "https://ui.ballmac.com/components/run-replay"
  },
  "categories": [
    "ai"
  ],
  "type": "registry:ui"
}