Elements

12 / 37 · Tool use

Tool timeline

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

Thinkingplanning the change
composer.tsx+143use-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.

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, SwapLabel } from "./surfaces";

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
      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"
        >
          <span className="relative inline-block leading-none">
            <span>{activeLabel}</span>
            <span
              aria-hidden
              className="shimmer pointer-events-none absolute inset-0 motion-reduce:animate-none"
            >
              {activeLabel}
            </span>
          </span>
          <>{restingLabel}</>
        </SwapLabel>
      </CollapsibleTrigger>
      <CollapsibleContent className={cn(collapsePanel, "outline-none")}>
        <div className="flex flex-col gap-2.5 ps-4 pt-2.5">
          {steps.slice(0, visibleSteps).map((step, index) => {
            const Icon = step.icon;
            const active = streaming && index === visibleSteps - 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" />
                <span className="relative inline-block leading-none">
                  <span>{step.verb}</span>
                  {active && (
                    <span
                      aria-hidden
                      className="shimmer pointer-events-none absolute inset-0 motion-reduce:animate-none"
                    >
                      {step.verb}
                    </span>
                  )}
                </span>
                <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>
  );
}