Elements

Tool timeline

A whole working session summarized as verbs, targets, and file stats.

Thinkingplanning the change
composer.tsx+143use-draft.ts+42
fig. 01 · plays once, replay from the corner

Installation

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 init

Then 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.

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

components/assistant-ui/elements/session-timeline.tsx
"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>

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

SelectorTypeDescription
s.message.partsreadonly ThreadAssistantMessagePart[]Every part in the message. Filter for part.type === "tool-call" to build the step list.
s.message.statusMessageStatus | undefinedstatus?.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.