Tool timeline
A whole working session summarized as verbs, targets, and file stats.
Installation
npx assistant-ui@latest add elements-tool-timelineThe CLI reads react-native from your package.json and installs from the native registry tree. The element takes the same props as the React one; the React Native elements guide covers setup and what changes on a phone.
npx shadcn@latest add "@assistant-ui/elements-tool-timeline"First time? Set up a runtime
Runtime components read their state from an assistant-ui runtime. Add one to an existing project:
npx assistant-ui@latest initThen wrap your app in a runtime provider:
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";
export default function App() {
const runtime = useChatRuntime({
transport: new AssistantChatTransport({ api: "/api/chat" }),
});
return (
<AssistantRuntimeProvider runtime={runtime}>
{/* your components */}
</AssistantRuntimeProvider>
);
}The installation guide covers new projects, templates, and API routes.
npx shadcn@latest add "@assistant-ui/elements-tool-timeline"Props-driven: no runtime or provider required.
A tool timeline turns everything one assistant turn did into a single collapsed line that expands into a vertical trace: a verb, an icon, and a chip per step, ending in a row of file-change stats. With a runtime you derive the steps from the message's own parts; standalone you hold the step list yourself.
Getting started
A single tool call renders through that tool's own render field, but a timeline summarizes every call in the message at once, so it reads s.message.parts directly instead of registering as one tool's renderer.
Derive steps from the message's parts
"use client";
import { useState } from "react";
import {
FileSearchIcon,
PenLineIcon,
TerminalIcon,
type LucideIcon,
} from "lucide-react";
import { useAuiState, type ToolCallMessagePart } from "@assistant-ui/react";
import {
ToolTimeline,
type TimelineStat,
type TimelineStep,
} from "@/components/assistant-ui/elements/tool-timeline";
const TOOL_META: Record<string, { verb: string; icon: LucideIcon }> = {
read_file: { verb: "Read", icon: FileSearchIcon },
run_command: { verb: "Ran", icon: TerminalIcon },
edit_file: { verb: "Edited", icon: PenLineIcon },
};
function toStep(part: ToolCallMessagePart): TimelineStep {
const meta = TOOL_META[part.toolName];
const args = part.args as Record<string, unknown>;
return {
verb: meta?.verb ?? part.toolName,
chip: String(args.path ?? args.command ?? part.toolCallId),
icon: meta?.icon ?? TerminalIcon,
};
}
function toStats(parts: readonly ToolCallMessagePart[]): TimelineStat[] {
return parts
.filter((part) => part.toolName === "edit_file" && part.result)
.map((part) => {
const result = part.result as { file: string; added: number; removed: number };
return { file: result.file, added: result.added, removed: result.removed };
});
}
export function SessionTimeline() {
const [open, setOpen] = useState(false);
const toolCalls = useAuiState((s) =>
s.message.parts.filter(
(part): part is ToolCallMessagePart => part.type === "tool-call",
),
);
const streaming = useAuiState((s) => s.message.status?.type === "running");
const steps = toolCalls.map(toStep);
const stats = toStats(toolCalls);
if (steps.length === 0) return null;
return (
<ToolTimeline
steps={steps}
visibleSteps={steps.length}
streaming={streaming}
open={open}
onOpenChange={setOpen}
restingLabel={`${steps.length} steps · ${stats.length} files changed`}
activeLabel="Working"
stats={stats}
/>
);
}Place it beside the message parts, not inside them
SessionTimeline replaces the per-part rendering for tool calls and reasoning, so silence those in MessagePrimitive.Parts to avoid showing the same steps twice:
<MessagePrimitive.Root>
<SessionTimeline />
<MessagePrimitive.Parts
components={{ tools: { Fallback: () => null }, Reasoning: () => null }}
/>
</MessagePrimitive.Root>Standalone, you hold the step and stat arrays as state and grow them as work happens.
Hold the step list
"use client";
import { useState } from "react";
import { FileSearchIcon, PenLineIcon, TerminalIcon } from "lucide-react";
import {
ToolTimeline,
type TimelineStat,
type TimelineStep,
} from "@/components/assistant-ui/elements/tool-timeline";
const STEPS: TimelineStep[] = [
{ verb: "Read", chip: "thread.tsx", icon: FileSearchIcon },
{ verb: "Ran", chip: "pnpm vitest", icon: TerminalIcon },
{ verb: "Edited", chip: "composer.tsx", icon: PenLineIcon },
];
const STATS: TimelineStat[] = [{ file: "composer.tsx", added: 14, removed: 3 }];
export function Session() {
const [open, setOpen] = useState(false);
return (
<ToolTimeline
steps={STEPS}
visibleSteps={STEPS.length}
streaming={false}
open={open}
onOpenChange={setOpen}
restingLabel="3 steps · 1 file changed"
activeLabel="Working"
stats={STATS}
/>
);
}Reveal steps as work happens
visibleSteps is independent of the array length, so a step can exist in steps before it is shown:
const [visibleSteps, setVisibleSteps] = useState(0);
useEffect(() => {
if (visibleSteps >= STEPS.length) return;
const id = setTimeout(() => setVisibleSteps((n) => n + 1), 1000);
return () => clearTimeout(id);
}, [visibleSteps]);Anatomy
<div data-slot="tool-timeline">
<button>{/* chevron, shimmering activeLabel while streaming, else restingLabel */}</button>
<div>
{/* one row per visible step: icon, verb, chip */}
{/* a wrapped row of file chips, only when stats.length > 0 */}
</div>
</div>visibleSteps clamps to the length of steps and floors a fractional or negative value to zero, so a step can be queued before it is shown. Only the last visible step shimmers, and only while streaming is true; every earlier step reads as settled even mid-run. The stats row is omitted entirely when stats is empty, and a stat missing added or removed omits that half of the count rather than showing it as zero.
Examples
Restyle the timeline
Both lanes take className on the root. Rows, chips, and the shimmering label all read from the shared tokens in surfaces.tsx.
<ToolTimeline className="max-w-none" /* ... */ />Showing only the recent tail
The panel reveals steps from the start of the array, so cap the array itself, not just visibleSteps, to show only the most recent steps of a long run:
<ToolTimeline steps={steps.slice(-6)} visibleSteps={6} /* ... */ />API reference
Message state
| Selector | Type | Description |
|---|---|---|
s.message.parts | readonly ThreadAssistantMessagePart[] | Every part in the message. Filter for part.type === "tool-call" to build the step list. |
s.message.status | MessageStatus | undefined | status?.type === "running" while the message is still streaming. |
There is no dedicated timeline primitive: you write the mapping from parts to steps and stats yourself, the way toStep and toStats do above. See Tool UI for the full ToolCallMessagePart shape.
ToolTimeline
| Prop | Type | Default | Description |
|---|---|---|---|
steps | readonly TimelineStep[] | required | The full step list, in order. |
visibleSteps | number | required | How many steps from the start of steps to render. |
streaming | boolean | required | Shimmers the trigger label and the last visible step while true. |
open | boolean | required | Whether the disclosure panel is expanded. |
onOpenChange | (open: boolean) => void | required | Called when the trigger is clicked. |
restingLabel | string | required | Trigger text shown once streaming is false. |
activeLabel | string | required | Shimmering trigger text shown while streaming is true. |
stats | TimelineStat[] | required | File-change chips rendered below the steps. Pass [] to omit the row. |
className | string | Merged onto the root. |
TimelineStep is { verb: string; chip: string; icon: LucideIcon }. TimelineStat is { file: string; added?: number; removed?: number }.