Agent Run Timeline UI
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.
npx shadcn@latest add https://scrimui.dev/r/agent-run-timeline.jsonAgent promptClaude Code · Cursor · any agent
Add the Agent Run Timeline UI component from the Scrim UI registry to this project and use it in the configuration described below.
Agent Run Timeline UI — 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.
## 1. Install
```bash
npx shadcn@latest add https://scrimui.dev/r/agent-run-timeline.json
```
This writes a single file to `components/ui/agent-run-timeline.tsx`. It is plain React + Tailwind with no runtime dependencies — no Radix, no CVA, nothing to add to package.json. If this project does not use the shadcn CLI, copy the source from https://scrimui.dev/components/agent-run-timeline to the same path by hand; nothing in the file depends on shadcn.
## 2. Use it
```tsx
import { AgentRunTimeline } from "@/components/ui/agent-run-timeline";
const EVENTS = [
{ id: "e1", kind: "model", title: "Planner drafted 6-step plan", at: "14:02:03", status: "completed", durationMs: 3200 },
{ id: "e2", kind: "tool", title: "search_docs(\"refund policy\")", at: "14:02:05", status: "completed", detail: "4 passages", durationMs: 1800 },
{ id: "e3", kind: "tool", title: "read_file(\"orders/1042.json\")", at: "14:02:07", status: "completed", durationMs: 640 },
{ id: "e4", kind: "tool", title: "generate_report()", at: "14:02:12", status: "running", detail: "Streaming sections" },
];
<AgentRunTimeline
elapsed="38s"
tokens={18420}
cost="$0.11"
emptyText="No events yet — the run's activity will appear here."
events={EVENTS}
summary={{ elapsed: "38s", tokens: 18420, cost: "$0.11" }}
onApprove={() => {}}
onReject={() => {}}
/>
```
## 3. The configuration that matters
This is the component's "Running" state — One active step, the rest folded — the reader sees the pulse, not the noise.
These props were set deliberately and should be preserved as written:
- `events` — Events (* running, ~ waiting, ! failed, x cancelled, > retry): pass the `EVENTS` value declared above the element. Keep its shape; the entries themselves are placeholder content to replace with real data.
Every other prop is at its default; leave those off the call site rather than writing the default value out.
## 4. Wire the handlers
`onApprove`, `onReject` are stubs in the snippet above (`() => {}`). Connect them to this project's own state and data layer instead of shipping the empty functions.
Reference: https://scrimui.dev/components/agent-run-timelineFollows the props below — change a control and the prompt changes with it, so an agent reproduces that configuration instead of the defaults.
- 14:02:12generate_report()Streaming sections
Presets
Props
Also takes onApprove, onReject.
Component source
Single-file React + Tailwind component. No dependencies — drop it into any project with Tailwind configured.
"use client";
import * as React from "react";
/**
* The long-run activity log for an agent — tens to hundreds of events.
*
* The rules this component holds:
*
* **Success collapses, trouble expands.** A healthy run is boring; the
* reader is here for the blocked approval and the failed tool call.
* Consecutive completed steps fold into a countable cluster — openable,
* never deleted — while waiting, running, failed and cancelled events are
* always visible.
*
* **A retry never overwrites its original.** The failed attempt stays in
* the log with its error; the retry lands as a new event linked to it.
* A log that silently rewrites history is a dashboard, not a record.
*
* **The list follows only a reader who's already at the bottom.** New
* events append without yanking someone reading step 12 of 80 — a "back
* to latest" pill carries them down when they choose.
*
* **Approvals keep their visual rank.** An approval gate is the only event
* that can spend money or touch production, so it never collapses and it
* never looks like a tool call.
*/
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export type RunEventKind = "model" | "tool" | "approval" | "handoff" | "error" | "note";
export type RunEventStatus = "running" | "waiting" | "completed" | "failed" | "cancelled";
export type RunEvent = {
/** Stable identity — never an array index. */
id: string;
kind: RunEventKind;
title: string;
detail?: string;
/** Display timestamp, e.g. "14:02:11". */
at: string;
status: RunEventStatus;
durationMs?: number;
/** The event this one retries. The original stays in the log, untouched. */
retryOf?: string;
};
export type RunSummary = {
tokens?: number;
cost?: string;
elapsed?: string;
};
export type AgentRunTimelineProps = {
events: RunEvent[];
onApprove?: (id: string) => void;
onReject?: (id: string) => void;
summary?: RunSummary;
emptyText?: string;
className?: string;
};
/* ------------------------------------------------------------------ */
/* Icons */
/* ------------------------------------------------------------------ */
const ICON_PROPS = {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
strokeLinecap: "round",
strokeLinejoin: "round",
} as const;
function ModelIcon() {
return (
<svg {...ICON_PROPS} width="13" height="13">
<path d="M12 8V4H8" />
<rect width="16" height="12" x="4" y="8" rx="2" />
<path d="M2 14h2" />
<path d="M20 14h2" />
<path d="M15 13v2" />
<path d="M9 13v2" />
</svg>
);
}
function ToolIcon() {
return (
<svg {...ICON_PROPS} width="13" height="13">
<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" />
</svg>
);
}
function ApprovalIcon() {
return (
<svg {...ICON_PROPS} width="13" height="13">
<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" />
<path d="m9 12 2 2 4-4" />
</svg>
);
}
function HandoffIcon() {
return (
<svg {...ICON_PROPS} width="13" height="13">
<path d="m17 3 4 4-4 4" />
<path d="M21 7H9" />
<path d="m7 21-4-4 4-4" />
<path d="M3 17h12" />
</svg>
);
}
function ErrorIcon() {
return (
<svg {...ICON_PROPS} width="13" height="13">
<circle cx="12" cy="12" r="10" />
<line x1="12" x2="12" y1="8" y2="12" />
<line x1="12.01" x2="12" y1="16" y2="16" />
</svg>
);
}
function NoteIcon() {
return (
<svg {...ICON_PROPS} width="13" height="13">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
);
}
const KIND_ICONS: Record<RunEventKind, React.ReactNode> = {
model: <ModelIcon />,
tool: <ToolIcon />,
approval: <ApprovalIcon />,
handoff: <HandoffIcon />,
error: <ErrorIcon />,
note: <NoteIcon />,
};
/* ------------------------------------------------------------------ */
/* Clustering — consecutive completed steps fold into one row */
/* ------------------------------------------------------------------ */
type Row = { type: "event"; event: RunEvent } | { type: "cluster"; key: string; events: RunEvent[] };
const COLLAPSIBLE_MIN = 3;
function isCollapsible(e: RunEvent) {
return e.status === "completed" && e.kind !== "approval";
}
function buildRows(events: RunEvent[]): Row[] {
const rows: Row[] = [];
let cluster: RunEvent[] = [];
const flush = () => {
if (cluster.length >= COLLAPSIBLE_MIN) {
rows.push({ type: "cluster", key: `c-${cluster[0].id}`, events: cluster });
} else {
cluster.forEach((e) => rows.push({ type: "event", event: e }));
}
cluster = [];
};
events.forEach((e) => {
if (isCollapsible(e)) {
cluster.push(e);
} else {
flush();
rows.push({ type: "event", event: e });
}
});
flush();
return rows;
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
/* ------------------------------------------------------------------ */
/* AgentRunTimeline */
/* ------------------------------------------------------------------ */
export function AgentRunTimeline({
events,
onApprove,
onReject,
summary,
emptyText = "No events yet — the run's activity will appear here.",
className = "",
}: AgentRunTimelineProps) {
const scrollRef = React.useRef<HTMLDivElement>(null);
const atBottomRef = React.useRef(true);
const [atBottom, setAtBottom] = React.useState(true);
const [expanded, setExpanded] = React.useState<Set<string>>(new Set());
const rows = buildRows(events);
const activeId = events.find((e) => e.status === "running" || e.status === "waiting")?.id;
/* Auto-follow: only while the reader is already at the bottom. The state
writes happen in the scroll/effect callbacks, never in render. */
React.useEffect(() => {
const el = scrollRef.current;
if (el && atBottomRef.current) el.scrollTop = el.scrollHeight;
}, [events.length]);
function onScroll() {
const el = scrollRef.current;
if (!el) return;
const at = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
atBottomRef.current = at;
setAtBottom(at);
}
function jumpToLatest() {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}
function toggleCluster(key: string) {
setExpanded((s) => {
const next = new Set(s);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
const retryTitles = new Map(events.map((e) => [e.id, e.title]));
function renderEvent(e: RunEvent) {
const highlight =
e.status === "failed" || e.status === "cancelled"
? "border-l-2 border-red-400 dark:border-red-500"
: e.kind === "approval" && e.status === "waiting"
? "border-l-2 border-amber-400 dark:border-amber-500"
: e.status === "running"
? "border-l-2 border-blue-400 dark:border-blue-500"
: "border-l-2 border-transparent";
return (
<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}`}>
<span className="w-14 shrink-0 text-[11px] tabular-nums text-zinc-400 dark:text-zinc-500">{e.at}</span>
<span className={`shrink-0 self-center ${e.status === "failed" ? "text-red-500" : "text-zinc-400 dark:text-zinc-500"}`}>
{KIND_ICONS[e.kind]}
</span>
<span className="min-w-0 flex-1">
<span className="text-[13px] font-medium text-zinc-800 dark:text-zinc-100">{e.title}</span>
{e.retryOf && (
<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">
retry of “{retryTitles.get(e.retryOf) ?? e.retryOf}”
</span>
)}
{e.detail && (
<span className="block truncate text-xs text-zinc-500 dark:text-zinc-400">{e.detail}</span>
)}
</span>
<span className="flex shrink-0 items-center gap-2 self-center">
{e.durationMs != null && (
<span className="text-[11px] tabular-nums text-zinc-400 dark:text-zinc-500">{formatDuration(e.durationMs)}</span>
)}
{e.status === "running" && (
<span className="h-2 w-2 animate-pulse rounded-full bg-blue-500" aria-label="Running" />
)}
{e.status === "waiting" && e.kind !== "approval" && (
<span className="text-[11px] font-medium text-amber-600 dark:text-amber-400">Waiting</span>
)}
{e.status === "failed" && (
<span className="text-[11px] font-medium text-red-600 dark:text-red-400">Failed</span>
)}
{e.status === "cancelled" && (
<span className="text-[11px] font-medium text-zinc-400 dark:text-zinc-500">Cancelled</span>
)}
</span>
{e.kind === "approval" && e.status === "waiting" && (onApprove || onReject) && (
<span className="flex w-full gap-2 pl-[4.75rem] pt-1">
{onApprove && (
<button
type="button"
onClick={() => onApprove(e.id)}
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"
>
Approve
</button>
)}
{onReject && (
<button
type="button"
onClick={() => onReject(e.id)}
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"
>
Reject
</button>
)}
</span>
)}
</li>
);
}
return (
<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}`}>
<div
ref={scrollRef}
onScroll={onScroll}
className="min-h-0 flex-1 overflow-y-auto py-2"
role="log"
aria-label="Agent run activity"
>
{events.length === 0 ? (
<p className="px-4 py-8 text-center text-xs text-zinc-500 dark:text-zinc-400">{emptyText}</p>
) : (
<ul>
{rows.map((row) =>
row.type === "event" ? (
renderEvent(row.event)
) : expanded.has(row.key) ? (
<React.Fragment key={row.key}>
<li className="py-1 pl-2">
<button
type="button"
onClick={() => toggleCluster(row.key)}
aria-expanded="true"
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"
>
Hide {row.events.length} completed steps
</button>
</li>
{row.events.map(renderEvent)}
</React.Fragment>
) : (
<li key={row.key} className="py-1 pl-2">
<button
type="button"
onClick={() => toggleCluster(row.key)}
aria-expanded="false"
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"
>
{row.events.length} completed steps · {row.events[0].at}–{row.events[row.events.length - 1].at}
</button>
</li>
),
)}
</ul>
)}
</div>
{!atBottom && (
<button
type="button"
onClick={jumpToLatest}
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"
>
↓ {activeId ? "Back to active step" : "Back to latest"}
</button>
)}
{summary && (summary.tokens != null || summary.cost || summary.elapsed) && (
<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">
{summary.elapsed && <span>Elapsed {summary.elapsed}</span>}
{summary.tokens != null && <span>{summary.tokens.toLocaleString()} tokens</span>}
{summary.cost && <span>{summary.cost}</span>}
</div>
)}
</div>
);
}
When to use it
- Give every event a stable id — a retry appends a new linked event and never overwrites the failed original.
- Collapse consecutive successes into countable clusters; waiting, running, failed and approvals never fold.
- Follow new events only while the reader is at the bottom — otherwise offer a 'back to latest' pill.
- Keep approval gates visually distinct and actionable in place; they're the only events that can spend or change.
- Attach tokens, time and cost as a summary so a long run is accountable, not just long.
What breaks in production
- Auto-scrolling on every new event — anyone reading step 12 of 80 gets yanked to the tail forever.
- A flat list where 60 identical tool calls drown the one failed lookup and the pending approval.
- Retrying by mutating the failed row — the log stops being a record and starts being a dashboard.
- Treating a cancelled run as an error — red everywhere teaches the reader to ignore red.
Related Components
Status for an AI agent working on its own — running, waiting on you, completed or failed, with what it did last.
The human-in-the-loop confirmation — what the agent wants to do, the exact details, and allow or deny before it acts.
The approval card as a lifecycle: a decision that survives a closed tab, is idempotent across two open ones, and says what happened when it lands after the request expired.
The checklist an agent writes for itself and then edits mid-run — steps added, skipped and re-ordered, without the list jumping under the reader.