Run scrubber
ProA 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.
Version 5.2.0· other versions
This page is for 5.2.0, the current release. An earlier release’s page is the Markdown that release published, so the docs match the version you have installed (candor-lock.json records it).
- 5.1.0
- 5.0.0
- 4.5.0
- 4.4.0
- 4.3.0
- 4.2.0
- 4.1.0
- 4.0.2
- 4.0.1
- 4.0.0
- 3.0.0
- 2.10.0
- 2.9.0
- 2.8.0
- 2.7.0
- 2.6.2
- 2.6.1
- 2.6.0
- 2.5.1
- 2.5.0
- 2.4.0
- 2.3.0
- 2.2.0
- 2.1.0
- 2.0.0
Not in the docs of 1.3.0, 1.4.0, 1.5.0, 1.5.1, 1.6.0, 1.7.0, 1.8.0, 1.8.1, 1.9.0 and 1.9.1: the page came later, or had another name then.
Docs pages weren’t kept for 1.0.0, 1.0.1, 1.1.0, 1.1.1, 1.2.0 and 1.2.1: they are kept from 1.3.0 on, and before that only llms.txt was published.
Preview
Loading the preview
Install
First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/run-scrubber
Brings in: bidi, button, locale, locked, motion, status, status-dot, tokens, utils · npm: lucide-react@^1.67.0
Usage
"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>
| Prop | Type | Default | Description |
|---|---|---|---|
| eventsrequired | 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 | 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 | — | Extra classes for the outer element. |
Strings
The fixed words, in English by default. Change any of them under runScrubber in <LocaleProvider strings={{ … }}>; the table is exported as runScrubberStrings.
| Key | English |
|---|---|
| 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. |
Motion
| Moment | Family | What it does |
|---|---|---|
| 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.
Types
- RunScrubberEvent
- One thing that happened during a run.
{ /** 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 from @/lib/candor/status
- 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.
"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"