# Run scrubber

A timeline for replaying a run: the app's `events`, a track with a tick at each event's real timestamp (spaced by the time between them, never evenly unless the times are), and a thumb the person drags or moves with the keyboard. Left and Right (or Up and Down) step to the previous or next event, Home and End go to the first and last. The thumb always rests on an event, never between, and the event it is on is shown beside the track with its status, its time since the first event and its own time. `onValueChange` gets the event's index in `events`, so the app can show that moment in its own trace, log or tree.

Play advances through the events by the time that really passed between them, divided by `speed` (1 means real time; 8 means eight times faster) and labelled with it. Events with the same timestamp follow one another at once, and the readout says how many share the moment. Playback pauses at the last event, and stops if it reaches an event with no readable time, since nothing is known about how long to wait. With `index` given, it also stops, and says so, if the app doesn't move `index` to the event `onValueChange` was called with: a controlled scrubber only plays as far as the app follows. Dragging or pressing a key pauses it. Nothing is estimated: a gap is as long as the timestamps say.

Pointing at the track, or focusing it, shows the title of the event at that place above the track, so a person can read where a press would go; screen readers hear the thumb's value instead.

- Registry name: `run-scrubber` (`@candor/run-scrubber`)
- Tier: Pro. Installing it needs a Pro, Premium or Team licence key: the person runs `npx candor-ui key <key>` themselves; never ask for the key in chat or write it to a file.
- Docs: https://candor.themethodic.com/docs/run-scrubber

## Install

```bash
npx candor-ui add run-scrubber
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/run-scrubber`.
It brings in: bidi, button, locale, locked, motion, status, status-dot, tokens, utils.
npm packages: lucide-react@^1.67.0.

## Example

```tsx
"use client"

import * as React from "react"
import { RunScrubber, type RunScrubberEvent } from "@/components/candor/run-scrubber"

/*
 * One run of Keel's weekly renewals check, as the app recorded it. The times are uneven on purpose: the track spaces
 * the ticks by them, and the two events at 14:03:09 say they share a moment. The app decides what a moment looks like;
 * here it only prints the event the scrubber landed on.
 */
const events: RunScrubberEvent[] = [
  { id: "e1", at: "2026-11-05T14:00:00Z", title: "Started: renewals check for Acme", status: "succeeded" },
  { id: "e2", at: "2026-11-05T14:00:04Z", title: "Read 41 open renewals from the CRM", status: "succeeded", detail: "41 rows, 3 columns used." },
  { id: "e3", at: "2026-11-05T14:02:41Z", title: "Drafted 12 reminder emails", status: "succeeded" },
  { id: "e4", at: "2026-11-05T14:03:09Z", title: "Asked Dana Whitfield to approve the Acme email", status: "waiting" },
  { id: "e5", at: "2026-11-05T14:03:09Z", title: "Paused the other 11 drafts until it is answered", status: "waiting" },
  { id: "e6", at: "2026-11-05T14:07:50Z", title: "Approved by Dana Whitfield", status: "succeeded" },
  { id: "e7", at: "2026-11-05T14:08:02Z", title: "Sent 12 emails", status: "failed", detail: "Sent 9. The mail server refused 3 addresses: the messages were not sent." },
]

export default function Example() {
  const [index, setIndex] = React.useState(0)
  const shown = events[index]!
  return (
    <div className="flex max-w-2xl flex-col gap-3">
      <RunScrubber events={events} index={index} onValueChange={setIndex} speed={20} />
      <p className="text-xs text-muted-foreground">
        The app’s own trace would show this moment: <span className="font-medium text-foreground">{shown.id}</span>, {shown.title}.
      </p>
    </div>
  )
}
```

## <RunScrubber>

Props:

- `events: RunScrubberEvent[]`: What happened, in any order.
- `index?: number | undefined`: The event shown, as an index into `events`. Makes the scrubber controlled: it moves only when the app changes this.
- `defaultIndex?: number | undefined`: The event to start on when `index` isn't given. Left out, the earliest event.
- `onValueChange?: ((index: number) => void) | undefined`: Called with the event's index in `events` when the person moves the thumb or playback reaches the next event.
- `speed?: number | undefined` (default `1`): How many times faster than real time Play runs. 1 is real time; anything that isn't a positive number is read as 1. The speed is printed beside Play.
- `className?: string | undefined`

## Strings (`runScrubber`)

The fixed words, English by default. Override any of them under `runScrubber` in `<LocaleProvider strings={{ runScrubber: { … } }}>`; the table is exported as `runScrubberStrings`.

- `track`: Position in the run
- `play`: Play
- `pause`: Pause
- `replay`: Replay
- `nothingToPlay`: There is only one event, so there is nothing to replay.
- `speed`: Plays at {speed}× real time.
- `playing`: Playing at {speed}× real time.
- `paused`: Paused.
- `ended`: Reached the last event. Playback stopped.
- `stoppedUnknown`: Playback stopped: the next event has no readable time, so how long to wait is not known.
- `notFollowed`: Playback stopped: the view did not move to the next event.
- `position`: Event {n} of {count}
- `elapsed`: {time} after the first event
- `atStart`: At the time of the first event
- `noTime`: Time unknown
- `sameTime`: { one: "{count} other event has exactly this timestamp.", other: "{count} other events have exactly this timestamp.", }
- `allSameTime`: Every event with a time has the same timestamp, so the track has no spread.
- `unplaced`: { one: "{count} event has no readable time and is not on the track.", other: "{count} events have no readable time and are not on the track.", }
- `valueText`: {n} of {count}: {title}, {time}
- `empty`: No events to replay.

## Types

### RunScrubberEvent

One thing that happened during a run.

```ts
{
  /** Stable across renders. */
  id: string;
  /** When it happened, as an ISO timestamp the app recorded. Unreadable values are kept in the list but not placed on the track. */
  at: string;
  /** What happened, in the app's words. */
  title: string;
  status: RunStatus;
  /** More about it: the tool called, the output, the reason it failed. */
  detail?: React.ReactNode | undefined;
}
```

## Types from other items

The props above take these. Each is defined by another item; import it from the path shown.

### RunStatus

Import: `import type { RunStatus } from "@/lib/candor/status"` (item `status`, https://candor.themethodic.com/docs/status.md)

The status vocabulary every Candor agent component shares. Components take plain data in these shapes rather than an SDK's message objects, so they work with your own LLM drivers, the Vercel AI SDK or anything else; adapters map an SDK's parts onto them.

```ts
"idle" // not started
 | "queued" // accepted, waiting for a worker
 | "running" // an agent is working on it now
 | "waiting" // stopped to ask a person (approval, question)
 | "blocked" // can't start: something it depends on failed or is waiting
 | "succeeded" | "failed" | "cancelled" | "skipped"
```

## Motion

- **preview** (arrive, rise): The event's title rises in above the track when the pointer comes onto it or it takes focus, and changes in place as the pointer moves or the keys step.

Set with `--cd-motion-run-scrubber-<moment>-*` in your brand file; the families are in the design reference.

## Rules for using Candor UI

- Components take plain typed data. Never pass model output as HTML or render it as a link; messages are typed blocks.
- A control that can't be used says why: wrap it in `<Locked reason="…">`, don't just disable it.
- Don't show a value you don't have: leave it out (it renders as —) rather than guess. An estimate is labelled as one.
- Nothing is pre-selected in a decision a person makes, and the agent is named from your app's data, never from model output.
- Your app's words go in `LocaleProvider`: its fixed strings in `strings`, its nouns in `nouns`; entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
