25 / 122 · Tool use
Tool timeline
A whole working session summarized as verbs, targets, and file stats.
composer.tsx+14−3use-draft.ts+42
Installation
npx shadcn@latest add "@assistant-ui/elements-tool-timeline"
Usage
import { FileSearchIcon } from "lucide-react";
import { ToolTimeline } from "@/components/elements/tool-timeline";
<ToolTimeline
steps={[{ verb: "Read", chip: "thread.tsx", icon: FileSearchIcon }]}
visibleSteps={1}
streaming
open
onOpenChange={setOpen}
restingLabel="Worked for 12s"
activeLabel="Working for 12s"
stats={[{ file: "thread.tsx", added: 4, removed: 1 }]}
/>Props
steps*readonly TimelineStep[]Working-session verbs, chips, and icons to reveal over time.
visibleSteps*numberHow many steps from the start are currently shown.
streaming*booleanWhen true the trigger shows Working with a shimmer and elapsed timer.
open*booleanWhether the collapsible timeline body is expanded.
onOpenChange*(open: boolean) => voidCalled when the user expands or collapses the timeline.
restingLabel*stringTrigger label once streaming finishes.
activeLabel*stringFull-size live label while streaming, for example Working for 12s.
stats*TimelineStat[]Per-file addition and removal counts listed under the steps.
classNamestringExtra classes merged onto the root.
This element normalizes its counts and shares at the boundary, so a value from outside its range reads as the nearest end rather than reaching the DOM: a negative count shows nothing, one past the end shows everything, and a share stays within 0 to 100 percent. A prop that names a selection is the exception, and means nothing is selected.
Source
tool-timeline.tsx"use client";
import { ChevronRightIcon, type LucideIcon } from "lucide-react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { collapsePanel, ShimmerLabel, SwapLabel } from "./surfaces";
import { take } from "./range";
export interface TimelineStep {
verb: string;
chip: string;
icon: LucideIcon;
}
export interface TimelineStat {
file: string;
added?: number;
removed?: number;
}
export interface ToolTimelineProps {
steps: readonly TimelineStep[];
visibleSteps: number;
streaming: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
restingLabel: string;
activeLabel: string;
stats: TimelineStat[];
className?: string;
}
export function ToolTimeline({
steps,
visibleSteps,
streaming,
open,
onOpenChange,
restingLabel,
activeLabel,
stats,
className,
}: ToolTimelineProps) {
return (
<Collapsible
data-slot="tool-timeline"
open={open}
onOpenChange={onOpenChange}
className={cn("w-full max-w-sm", className)}
>
<CollapsibleTrigger className="group/trigger text-foreground/55 hover:text-foreground/90 flex items-center gap-1.5 rounded-md py-1 text-[13.5px] transition-colors outline-none">
<ChevronRightIcon className="size-3.5 shrink-0 opacity-60 transition-transform duration-200 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-open/trigger:rotate-90 group-data-panel-open/trigger:rotate-90 motion-reduce:transition-none" />
<SwapLabel
active={streaming ? 0 : 1}
className="text-start tabular-nums"
>
<ShimmerLabel
active={streaming}
className="relative inline-block leading-none"
>
{activeLabel}
</ShimmerLabel>
<>{restingLabel}</>
</SwapLabel>
</CollapsibleTrigger>
<CollapsibleContent className={cn(collapsePanel, "outline-none")}>
<div className="flex flex-col gap-2.5 ps-4 pt-2.5">
{take(steps, visibleSteps).map((step, index, shown) => {
const Icon = step.icon;
const active = streaming && index === shown.length - 1;
return (
<div
key={step.chip}
className="fade-in slide-in-from-bottom-1 animate-in fill-mode-both text-foreground/55 flex items-center gap-2 text-[13.5px] duration-300"
>
<Icon className="text-foreground/35 size-3.5 shrink-0" />
<ShimmerLabel
active={active}
className="relative inline-block leading-none"
>
{step.verb}
</ShimmerLabel>
<span className="bg-foreground/[0.06] text-foreground/70 rounded-md px-1.5 py-0.5 font-mono text-[11px]">
{step.chip}
</span>
</div>
);
})}
{stats.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{stats.map((stat) => (
<span
key={stat.file}
className="bg-foreground/[0.06] text-foreground/70 inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-mono text-[11px]"
>
<span>{stat.file}</span>
{stat.added !== undefined && (
<span className="text-emerald-600 dark:text-emerald-400">
+{stat.added}
</span>
)}
{stat.removed !== undefined && (
<span className="text-red-600 dark:text-red-400">
−{stat.removed}
</span>
)}
</span>
))}
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}