Thought Trail
An agent's steps as they happen. The step at work shimmers and streams its details; when the next one starts, it folds up into a one-line summary and the rail fills down to the new step. Click any summary to open it again.
- AI
- Status
- Motion
Uses motion, clsx, tailwind-merge
Task
Add a dark mode toggle to the settings page
"use client";
import * as React from "react";
import { AnimatePresence, motion, useInView } from "motion/react";
import { cn } from "@/lib/utils";
import { ThoughtTrail, ThoughtTrailStep } from "@/components/ui/thought-trail";
type Step = {
title: string;
summary: string;
/** How long the step runs, in ms. Its details stream in over the first 85%. */
ms: number;
} & (
| { kind: "files"; files: string[] }
| { kind: "text"; text: string }
| { kind: "diff"; lines: string[] }
| { kind: "log"; lines: string[] }
);
const task = "Add a dark mode toggle to the settings page";
const steps: Step[] = [
{
title: "Reading the codebase",
summary: "Read 6 files",
ms: 2400,
kind: "files",
files: [
"app/settings/page.tsx",
"components/theme-provider.tsx",
"app/layout.tsx",
"components/ui/switch.tsx",
"lib/storage.ts",
"app/globals.css",
],
},
{
title: "Planning the change",
summary: "Planned 3 edits",
ms: 3600,
kind: "text",
text: "ThemeProvider already wraps the app, but nothing ever changes the theme. The settings page has a Preferences section with a Switch for email digests, so a second Switch fits there. It should call setTheme, save the choice through lib/storage.ts, and follow the system setting until someone flips it.",
},
{
title: "Editing settings/page.tsx",
summary: "Edited settings/page.tsx",
ms: 3200,
kind: "diff",
lines: [
'+import { useTheme } from "@/components/theme-provider";',
" export default function SettingsPage() {",
"+ const { theme, setTheme } = useTheme();",
' <Section title="Preferences">',
'+ <Row label="Dark mode">',
"+ <Switch",
'+ checked={theme === "dark"}',
'+ onCheckedChange={(on) => setTheme(on ? "dark" : "light")}',
"+ />",
"+ </Row>",
],
},
{
title: "Running the type check",
summary: "Type check passed",
ms: 2000,
kind: "log",
lines: ["$ tsc --noEmit", "Checking 214 files…", "✓ No errors"],
},
];
const reply =
"Added a Dark mode switch under Preferences. It follows the system setting until you flip it, then remembers the choice.";
const LEAD = 600; // ms before the first step, so it arrives rather than just being there
const starts = steps.map((_, i) => LEAD + steps.slice(0, i).reduce((sum, s) => sum + s.ms, 0));
const END = LEAD + steps.reduce((sum, s) => sum + s.ms, 0);
/** Milliseconds into the current run, from when the demo first scrolls into view. Stops at `end`. */
function usePlayhead(run: number, playing: boolean, end: number) {
const [clock, setClock] = React.useState({ run, t: 0 });
React.useEffect(() => {
if (!playing) return;
const start = performance.now();
const timer = window.setInterval(() => {
const t = Math.min(end, performance.now() - start);
setClock({ run, t });
if (t >= end) window.clearInterval(timer);
}, 50);
return () => window.clearInterval(timer);
}, [run, playing, end]);
return clock.run === run ? clock.t : 0;
}
// A scripted agent run. Each step opens while it works and shrinks to its
// summary when the next one starts; when the run ends, the trail folds into
// its header. Click any summary to open it again.
export function ThoughtTrailDemo() {
const ref = React.useRef<HTMLDivElement>(null);
const playing = useInView(ref, { once: true, amount: 0.4 });
const [run, setRun] = React.useState(0);
const t = usePlayhead(run, playing, END + 400);
const finished = t >= END;
return (
<div
ref={ref}
className="flex h-[30rem] w-full max-w-xl flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm shadow-zinc-950/5 dark:border-zinc-800 dark:bg-zinc-950 dark:shadow-black/40"
>
<div className="flex items-center gap-4 border-b border-zinc-100 px-5 py-3.5 dark:border-zinc-800/80">
<div className="min-w-0 flex-1">
<p className="text-[11px] font-medium tracking-wide text-zinc-400 uppercase dark:text-zinc-500">Task</p>
<p className="mt-0.5 truncate text-sm text-zinc-900 dark:text-zinc-100">{task}</p>
</div>
<button
type="button"
onClick={() => setRun((r) => r + 1)}
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg px-2.5 text-xs font-medium text-zinc-600 transition-[background-color,color,scale] duration-150 outline-none select-none hover:bg-zinc-100 hover:text-zinc-950 focus-visible:ring-2 focus-visible:ring-zinc-400 active:scale-[0.96] dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 dark:focus-visible:ring-zinc-600"
>
<svg viewBox="0 0 16 16" fill="none" aria-hidden className="size-3.5">
<path
d="M2.75 8a5.25 5.25 0 1 0 1.54-3.71M2.75 2.75v2.5h2.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Replay
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{playing ? (
<ThoughtTrail running={!finished}>
{steps.map((step, i) => {
if (t < starts[i]) return null;
const progress = Math.min(1, (t - starts[i]) / (step.ms * 0.85));
const done = t >= starts[i] + step.ms;
return (
<ThoughtTrailStep
key={step.title}
status={done ? "done" : "active"}
title={step.title}
summary={step.summary}
meta={step.kind === "diff" && done ? <DiffStat lines={step.lines} /> : undefined}
// The diff scrolls inside its step, pinned to the newest line. The top
// padding is the fade zone, so nothing is faded until it scrolls.
contentClassName={
step.kind === "diff"
? "max-h-40 overflow-y-auto pt-3 [mask-image:linear-gradient(to_bottom,transparent,#000_0.75rem)]"
: undefined
}
>
<StepDetails step={step} progress={progress} />
</ThoughtTrailStep>
);
})}
</ThoughtTrail>
) : null}
<AnimatePresence>
{finished ? (
<motion.p
key={run}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut", delay: 0.15 }}
className="mt-3 text-sm leading-relaxed text-pretty text-zinc-800 dark:text-zinc-200"
>
{reply}
</motion.p>
) : null}
</AnimatePresence>
</div>
</div>
);
}
function StepDetails({ step, progress }: { step: Step; progress: number }) {
const upTo = (n: number) => Math.ceil(progress * n);
if (step.kind === "files") {
return (
<div className="flex flex-wrap gap-1.5">
{step.files.slice(0, upTo(step.files.length)).map((file) => (
<span
key={file}
className="inline-flex items-center gap-1.5 rounded-md border border-zinc-200 bg-zinc-50 px-1.5 py-0.5 font-mono text-[11.5px] text-zinc-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400"
>
<svg viewBox="0 0 16 16" fill="none" aria-hidden className="size-3 text-zinc-400 dark:text-zinc-500">
<path
d="M4 1.75h5L12.25 5v9.25H4z M9 1.75V5h3.25"
stroke="currentColor"
strokeWidth="1.25"
strokeLinejoin="round"
/>
</svg>
{file}
</span>
))}
</div>
);
}
if (step.kind === "text") {
const words = step.text.split(" ");
return <p className="text-pretty">{words.slice(0, upTo(words.length)).join(" ")}</p>;
}
return (
<pre className="font-mono text-[11.5px] leading-5 whitespace-pre-wrap">
{step.lines.slice(0, upTo(step.lines.length)).map((line, i) => (
<span
key={i}
className={cn(
"block",
step.kind === "diff" && line.startsWith("+") && "text-emerald-700 dark:text-emerald-400",
step.kind === "log" && line.startsWith("✓") && "text-emerald-700 dark:text-emerald-400",
)}
>
{line}
</span>
))}
</pre>
);
}
function DiffStat({ lines }: { lines: string[] }) {
const added = lines.filter((line) => line.startsWith("+")).length;
return <span className="text-emerald-600 dark:text-emerald-400">+{added}</span>;
}
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/thought-trail.tsx and installs motion, clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/thought-trailInstall the dependencies
npm i motion clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
components/ui/thought-trail.tsx"use client"; import * as React from "react"; import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform, type MotionValue, type Transition, } from "motion/react"; import { cn } from "@/lib/utils"; /* ───────────────────────────────────────────────────────── * THOUGHT TRAIL STORYBOARD * * arrive a new step opens in at the bottom: its row grows * from nothing as it fades down into place, so the * trail lengthens instead of jumping * work its title shimmers, a band of light sweeping left * to right; an arc spins in its dot, and its details * open underneath and grow with each line that * streams in * hand off the next step starts: the last one's details fold * away, its title rolls up into its summary, the arc * draws into a check and the rail fills down toward * the new step, which is already opening. One spring * for all of it, so the height eases, never jolts * revisit click a summary to open it again. A step you * opened stays open; the trail only closes the ones * it opened itself * fail the dot becomes a red cross with a small shake, * the rail stops there, and the step stays open * finish the header's clock stops and its label rolls to * "Worked for 14s · 5 steps"; a beat later the whole * trail folds up into that line. A failed run stays * open * fold fold the trail by hand while it runs and the * header keeps the current step's title, shimmering * reduced no shimmer, slide or roll: heights change at * once and labels crossfade * ───────────────────────────────────────────────────────── */ const TRAIL = { fold: { type: "spring", visualDuration: 0.34, bounce: 0 }, // details opening and closing, steps arriving grow: { type: "spring", visualDuration: 0.26, bounce: 0 }, // details following what streams in roll: { type: "spring", visualDuration: 0.3, bounce: 0.1 }, // a title rolling into its summary pop: { type: "spring", visualDuration: 0.24, bounce: 0.2 }, // dot to arc to check show: { duration: 0.22, ease: "easeOut", delay: 0.06 }, hide: { duration: 0.12, ease: "easeIn" }, draw: { duration: 0.3, ease: [0.65, 0, 0.35, 1] }, // check and cross strokes rail: { duration: 0.45, ease: [0.22, 1, 0.36, 1] }, sheen: 1.8, // s for the band of light to cross a title spin: 0.9, // s per turn of the working arc settle: 0.8, // s the finished header holds before the trail folds into it tick: 250, // ms between clock readings drift: 6, // px details slide as they fade } as const satisfies Record<string, Transition | number>; export type ThoughtTrailStatus = "pending" | "active" | "done" | "error"; const TONE: Record<ThoughtTrailStatus, string> = { pending: "text-zinc-400 dark:text-zinc-600", active: "text-zinc-900 dark:text-zinc-100", done: "text-zinc-500 dark:text-zinc-400", error: "text-rose-600 dark:text-rose-400", }; const DOT_TONE: Record<ThoughtTrailStatus, string> = { pending: "text-zinc-300 dark:text-zinc-700", active: "text-zinc-900 dark:text-zinc-100", done: "text-zinc-400 dark:text-zinc-500", error: "text-rose-500 dark:text-rose-400", }; // The title's own color at 60%, with a band of the full color through the middle. const DIM = "color-mix(in oklab, currentColor 60%, transparent)"; const SHEEN = `linear-gradient(90deg, ${DIM} 0%, ${DIM} 41%, currentColor 50%, ${DIM} 59%, ${DIM} 100%)`; const FOCUS = "rounded-md outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-600"; /* ── What the steps tell the header ─────────────────────── */ type Entry = { status: ThoughtTrailStatus; title: React.ReactNode }; type Snapshot = { /** Steps that have started. */ steps: number; failed: boolean; /** Title of the latest active step. */ current: React.ReactNode; }; /** * Steps write here from layout effects and the header reads it, so a step's * update re-renders the header, never the steps around it. The trail itself * only listens for `failed`. */ function createStore() { const entries = new Map<string, Entry>(); const listeners = new Set<() => void>(); let snapshot: Snapshot = { steps: 0, failed: false, current: null }; let ready = false; const publish = () => { let steps = 0; let failed = false; let current: React.ReactNode = null; for (const entry of entries.values()) { if (entry.status !== "pending") steps++; if (entry.status === "error") failed = true; if (entry.status === "active") current = entry.title; } if (snapshot.steps === steps && snapshot.failed === failed && Object.is(snapshot.current, current)) return; snapshot = { steps, failed, current }; listeners.forEach((listener) => listener()); }; return { set(id: string, entry: Entry) { entries.set(id, entry); publish(); }, remove(id: string) { entries.delete(id); publish(); }, subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; }, snapshot: () => snapshot, failed: () => snapshot.failed, /** False until the trail's first commit: steps that mount after it arrive. */ ready: () => ready, markReady() { ready = true; }, }; } type Store = ReturnType<typeof createStore>; const TrailContext = React.createContext<{ store: Store; reduced: boolean } | null>(null); function useTrail() { const trail = React.useContext(TrailContext); if (!trail) throw new Error("<ThoughtTrailStep> must be rendered inside <ThoughtTrail>."); return trail; } const subscribe = () => () => {}; /* ── ThoughtTrail ───────────────────────────────────────── */ export interface ThoughtTrailDoneInfo { /** How long the run took, rounded to whole seconds: `duration`, or the trail's own clock. Null when neither knows. */ seconds: number | null; /** Steps that started. */ steps: number; /** A step ended with `status="error"`. */ failed: boolean; } export interface ThoughtTrailProps extends React.HTMLAttributes<HTMLDivElement> { /** The agent is working. The header shimmers and counts seconds; when it turns false, the trail finishes. */ running?: boolean; /** Header text while running. */ label?: React.ReactNode; /** Header text once finished. Defaults to "Worked for 14s · 5 steps", or "Stopped after 9s" when a step failed. */ doneLabel?: (info: ThoughtTrailDoneInfo) => React.ReactNode; /** Seconds the run took, for a trail restored from history. Overrides the trail's own clock. */ duration?: number; /** Fold the trail into its header a beat after it finishes. Failed runs stay open. */ collapseOnDone?: boolean; /** Open on mount. Defaults to `running || !collapseOnDone`, so a finished trail from history starts folded. */ defaultOpen?: boolean; /** Control the fold yourself. The trail then never opens or folds on its own. */ open?: boolean; /** Called when the reader clicks the header. */ onOpenChange?: (open: boolean) => void; /** Shown before the header text, in the column of the step dots. */ icon?: React.ReactNode; } /** * An agent's steps as they happen. Each step shows its details while it's * active, then shrinks to a one-line summary when it's done. */ export function ThoughtTrail({ running = false, label = "Working", doneLabel = defaultDoneLabel, duration, collapseOnDone = true, defaultOpen, open: openProp, onOpenChange, icon, className, children, ...props }: ThoughtTrailProps) { const [store] = React.useState(createStore); // False while rendering on the server and hydrating that HTML. const hydrated = React.useSyncExternalStore(subscribe, () => true, () => false); const reduced = (useReducedMotion() ?? false) && hydrated; const failed = React.useSyncExternalStore(store.subscribe, store.failed, store.failed); const bodyId = React.useId(); React.useEffect(() => store.markReady(), [store]); const [openState, setOpenState] = React.useState(defaultOpen ?? (running || !collapseOnDone)); const [touched, setTouched] = React.useState(false); const [run, setRun] = React.useState({ running, failed, ended: false }); if (run.running !== running || run.failed !== failed) { const started = running && !run.running; setRun({ running, failed, ended: started ? false : run.ended || (!running && run.running) }); // A new run opens the trail, and so does a failure. if (started) { setTouched(false); setOpenState(true); } else if (failed && !run.failed) { setOpenState(true); } } // A run that finishes folds into its header after a beat, unless the reader took over. const fold = openProp === undefined && collapseOnDone && run.ended && !failed && !touched && openState; React.useEffect(() => { if (!fold) return; const timer = window.setTimeout(() => setOpenState(false), TRAIL.settle * 1000); return () => window.clearTimeout(timer); }, [fold]); const open = openProp ?? openState; const toggle = () => { setTouched(true); setOpenState(!open); onOpenChange?.(!open); }; const trail = React.useMemo(() => ({ store, reduced }), [store, reduced]); return ( <TrailContext.Provider value={trail}> <div className={cn("w-full min-w-0 text-sm", className)} {...props}> <Header store={store} running={running} open={open} onToggle={toggle} controls={bodyId} label={label} doneLabel={doneLabel} duration={duration} icon={icon} reduced={reduced} /> <Fold id={bodyId} open={open} reduced={reduced}> <ol className="pt-1">{children}</ol> </Fold> </div> </TrailContext.Provider> ); } function defaultDoneLabel({ seconds, steps, failed }: ThoughtTrailDoneInfo) { const time = seconds === null ? null : formatSeconds(seconds); const head = failed ? (time ? `Stopped after ${time}` : "Stopped") : time ? `Worked for ${time}` : "Done"; return steps > 0 ? `${head} · ${steps} ${steps === 1 ? "step" : "steps"}` : head; } function formatSeconds(seconds: number) { if (seconds < 60) return `${seconds}s`; return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`; } /** Seconds since `running` last turned true, read every TRAIL.tick ms and held once it turns false. */ function useClock(running: boolean) { const [clock, setClock] = React.useState<{ running: boolean; seconds: number | null }>({ running, seconds: null, }); // A new run starts from zero, before its first reading. if (clock.running !== running) setClock({ running, seconds: running ? 0 : clock.seconds }); React.useEffect(() => { if (!running) return; const start = performance.now(); const read = () => setClock({ running: true, seconds: (performance.now() - start) / 1000 }); const timer = window.setInterval(read, TRAIL.tick); return () => { window.clearInterval(timer); const seconds = (performance.now() - start) / 1000; setClock({ running: false, seconds }); }; }, [running]); return clock.seconds; } function Header({ store, running, open, onToggle, controls, label, doneLabel, duration, icon, reduced, }: { store: Store; running: boolean; open: boolean; onToggle: () => void; controls: string; label: React.ReactNode; doneLabel: (info: ThoughtTrailDoneInfo) => React.ReactNode; duration: number | undefined; icon: React.ReactNode; reduced: boolean; }) { const { steps, failed, current } = React.useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot); const seconds = useClock(running); const done = doneLabel({ seconds: duration ?? (seconds === null ? null : Math.max(1, Math.round(seconds))), steps, failed, }); return ( <> <button type="button" aria-expanded={open} aria-controls={controls} onClick={onToggle} className={cn( "flex h-7 max-w-full items-center gap-2 text-left transition-colors duration-200", FOCUS, running ? TONE.active : failed ? TONE.error : "text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100", )} > {icon ? ( <span aria-hidden className="flex size-5 shrink-0 items-center justify-center [&_svg]:size-4"> {icon} </span> ) : null} <Swap id={running ? "running" : "done"} reduced={reduced} className="min-w-0"> {running ? ( <span className="flex min-w-0 items-baseline gap-1.5"> <Shimmer active reduced={reduced} className="shrink-0"> {label} </Shimmer> <span className="shrink-0 text-xs text-zinc-400 tabular-nums dark:text-zinc-500"> {formatSeconds(Math.floor(seconds ?? 0))} </span> {/* Folded by hand mid-run: keep the current step in view. */} {!open && current ? ( <> <span aria-hidden className="text-zinc-300 dark:text-zinc-600"> · </span> <Shimmer active reduced={reduced} className="min-w-0"> {current} </Shimmer> </> ) : null} </span> ) : ( <span className="block truncate">{done}</span> )} </Swap> <Chevron open={open} /> </button> <span className="sr-only" aria-live="polite"> {running ? current : done} </span> </> ); } /* ── ThoughtTrailStep ───────────────────────────────────── */ export interface ThoughtTrailStepProps { /** Where the step is. `active` shows its details and shimmers; `done` shrinks it to its summary. */ status?: ThoughtTrailStatus; /** What the step is doing, e.g. "Searching the docs". Shimmers while active. */ title: React.ReactNode; /** What it did, e.g. "Read 4 pages". Replaces the title once done (or failed). */ summary?: React.ReactNode; /** Replaces the dot and check. While active, the working arc circles it. */ icon?: React.ReactNode; /** Small text at the end of the row: a duration, a count. */ meta?: React.ReactNode; /** Classes for the details box. Give it `max-h-40 overflow-y-auto` and, while active, it keeps the newest line in view. */ contentClassName?: string; className?: string; /** The details: streamed text, logs, sources. Shown while active or failed, and when the reader opens the step. */ children?: React.ReactNode; } export function ThoughtTrailStep({ status = "done", title, summary, icon, meta, contentClassName, className, children, }: ThoughtTrailStepProps) { const { store, reduced } = useTrail(); const id = React.useId(); const detailsId = `${id}details`; // Steps in the trail's first render are just there; later ones arrive. const [arrives] = React.useState(() => store.ready() && !reduced); // Null until the reader clicks; until then the step opens while it works. const [chosen, setChosen] = React.useState<boolean | null>(null); const [seen, setSeen] = React.useState(status); if (seen !== status) { setSeen(status); // A failure shows its details, whatever the reader did before. if (status === "error") setChosen(null); } const hasDetails = children !== undefined && children !== null && children !== false; const canOpen = hasDetails && status !== "pending"; const open = canOpen && (chosen ?? (status === "active" || status === "error")); const showSummary = (status === "done" || status === "error") && summary != null; React.useLayoutEffect(() => store.set(id, { status, title })); React.useLayoutEffect(() => () => store.remove(id), [store, id]); const label = ( // Keyed on the status, so every change of state rolls the line. <Swap id={showSummary ? "summary" : status} reduced={reduced} className="flex-1"> {showSummary ? ( <span className="block truncate">{summary}</span> ) : ( <Shimmer active={status === "active"} reduced={reduced}> {title} </Shimmer> )} </Swap> ); const row = cn( "col-start-2 row-start-1 flex h-7 min-w-0 items-center gap-2 text-left transition-colors duration-200", TONE[status], ); const extra = meta ? ( <span className="shrink-0 text-xs text-zinc-400 tabular-nums dark:text-zinc-500">{meta}</span> ) : null; return ( <Fold as="li" open appear={arrives} reduced={reduced} className={cn("group/step relative", className)}> <div className="grid grid-cols-[1.25rem_minmax(0,1fr)] gap-x-2"> <Rail status={status} reduced={reduced} /> <Dot status={status} icon={icon} reduced={reduced} /> {canOpen ? ( <button type="button" aria-expanded={open} aria-controls={detailsId} onClick={() => setChosen(!open)} className={cn( row, FOCUS, "group/row", status === "done" && "hover:text-zinc-900 dark:hover:text-zinc-100", )} > {label} {extra} <Chevron open={open} className={cn( !open && "opacity-0 group-hover/row:opacity-100 group-focus-visible/row:opacity-100 [@media(hover:none)]:opacity-60", )} /> </button> ) : ( <div className={row}> {label} {extra} </div> )} {hasDetails ? ( <Fold id={detailsId} open={open} smooth reduced={reduced} className="col-start-2"> <Details pinned={status === "active"} className={contentClassName}> {children} </Details> </Fold> ) : null} </div> </Fold> ); } /** The line down to the next step. It fills in once this step is done. */ function Rail({ status, reduced }: { status: ThoughtTrailStatus; reduced: boolean }) { return ( <span aria-hidden className={cn( // From just under this dot to just above the next, which sits 7px into its row. "pointer-events-none absolute top-6 -bottom-1 left-[calc(0.625rem-0.5px)] w-px overflow-hidden rounded-full transition-[opacity,background-color] duration-300 group-last/step:opacity-0", status === "error" ? "bg-rose-200 dark:bg-rose-500/30" : "bg-zinc-200 dark:bg-zinc-800", )} > <motion.span className="absolute inset-0 origin-top bg-zinc-400 dark:bg-zinc-500" initial={false} animate={{ scaleY: status === "done" ? 1 : 0 }} transition={reduced ? { duration: 0 } : TRAIL.rail} /> </span> ); } function Dot({ status, icon, reduced, }: { status: ThoughtTrailStatus; icon: React.ReactNode; reduced: boolean; }) { const key = status === "error" ? "error" : icon ? (status === "active" ? "icon-active" : "icon") : status; let glyph: React.ReactNode; if (status === "error") glyph = <Cross reduced={reduced} />; else if (icon) glyph = ( <span className="relative flex size-5 items-center justify-center"> <span className="flex [&_svg]:size-3.5">{icon}</span> {status === "active" ? <Spinner reduced={reduced} className="absolute inset-0 size-5" ring /> : null} </span> ); else if (status === "active") glyph = <Spinner reduced={reduced} className="size-3.5" />; else if (status === "done") glyph = <Check reduced={reduced} />; else glyph = <span className="size-2 rounded-full border border-current" />; return ( <span aria-hidden className="relative col-start-1 row-start-1 flex h-7 items-center justify-center"> <AnimatePresence initial={false} mode="popLayout"> <motion.span key={key} className={cn("flex size-5 items-center justify-center transition-colors duration-200", DOT_TONE[status])} initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.5 }} animate={{ opacity: 1, scale: 1 }} exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.5 }} transition={reduced ? { duration: 0.15 } : TRAIL.pop} > {glyph} </motion.span> </AnimatePresence> </span> ); } function Spinner({ reduced, ring = false, className }: { reduced: boolean; ring?: boolean; className?: string }) { return ( <motion.svg viewBox="0 0 20 20" fill="none" className={className} // Reduced motion: a slow breath instead of a turn. animate={reduced ? { opacity: [1, 0.35, 1] } : { rotate: 360 }} transition={ reduced ? { duration: 1.6, ease: "easeInOut", repeat: Infinity } : { duration: TRAIL.spin, ease: "linear", repeat: Infinity } } > <circle cx="10" cy="10" r={ring ? 8.5 : 7} stroke="currentColor" strokeOpacity="0.18" strokeWidth={ring ? 1.25 : 2} /> <path d={ring ? "M10 1.5a8.5 8.5 0 0 1 8.5 8.5" : "M10 3a7 7 0 0 1 7 7"} stroke="currentColor" strokeWidth={ring ? 1.25 : 2} strokeLinecap="round" /> </motion.svg> ); } function Check({ reduced }: { reduced: boolean }) { return ( <svg viewBox="0 0 20 20" fill="none" className="size-3.5"> <motion.path d="M4.5 10.5l3.5 3.5 7.5-8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" initial={{ pathLength: reduced ? 1 : 0 }} animate={{ pathLength: 1 }} transition={TRAIL.draw} /> </svg> ); } function Cross({ reduced }: { reduced: boolean }) { return ( <motion.svg viewBox="0 0 20 20" fill="none" className="size-3.5" animate={reduced ? undefined : { x: [0, -2.5, 2.5, -1.5, 0] }} transition={{ duration: 0.4, delay: 0.15 }} > {["M5.5 5.5l9 9", "M14.5 5.5l-9 9"].map((d, i) => ( <motion.path key={d} d={d} stroke="currentColor" strokeWidth="2" strokeLinecap="round" initial={{ pathLength: reduced ? 1 : 0 }} animate={{ pathLength: 1 }} transition={{ ...TRAIL.draw, delay: i * 0.08 }} /> ))} </motion.svg> ); } function Chevron({ open, className }: { open: boolean; className?: string }) { return ( <svg viewBox="0 0 16 16" fill="none" aria-hidden className={cn( "size-3.5 shrink-0 text-zinc-400 transition-[rotate,opacity] duration-200 ease-out dark:text-zinc-500", open ? "rotate-0" : "-rotate-90", className, )} > <path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> </svg> ); } /* ── Building blocks ────────────────────────────────────── */ /** Text that a band of light sweeps across while `active`. Inherits its color. */ function Shimmer({ active, reduced, className, children, }: { active: boolean; reduced: boolean; className?: string; children: React.ReactNode; }) { // Inline-block, so the band crosses the words rather than the whole row. const base = cn("inline-block max-w-full truncate align-top", className); if (!active || reduced) return <span className={base}>{children}</span>; return ( <motion.span className={base} style={{ backgroundImage: SHEEN, backgroundSize: "250% 100%", backgroundClip: "text", WebkitBackgroundClip: "text", // Not `color`: the gradient is drawn in currentColor. WebkitTextFillColor: "transparent", }} initial={{ backgroundPosition: "100% 0%" }} animate={{ backgroundPosition: "0% 0%" }} transition={{ duration: TRAIL.sheen, ease: "linear", repeat: Infinity, repeatDelay: 0.2 }} > {children} </motion.span> ); } /** One line of text that rolls up into the next when `id` changes. */ function Swap({ id, reduced, className, children, }: { id: string; reduced: boolean; className?: string; children: React.ReactNode; }) { return ( <span className={cn("relative block min-w-0", className)}> <AnimatePresence initial={false} mode="popLayout"> <motion.span key={id} className="block min-w-0" initial={reduced ? { opacity: 0 } : { opacity: 0, y: "0.75em", filter: "blur(2px)" }} animate={{ opacity: 1, y: "0em", filter: "blur(0px)" }} exit={reduced ? { opacity: 0 } : { opacity: 0, y: "-0.75em", filter: "blur(2px)" }} transition={reduced ? { duration: 0.15 } : TRAIL.roll} > {children} </motion.span> </AnimatePresence> </span> ); } /** A step's details. While pinned, a scrolling box keeps its newest line in view, until the reader scrolls up. */ function Details({ pinned, className, children, }: { pinned: boolean; className?: string; children: React.ReactNode; }) { const ref = React.useRef<HTMLDivElement>(null); React.useEffect(() => { const box = ref.current; if (!box || !pinned) return; let stuck = true; const follow = () => { if (stuck) box.scrollTop = box.scrollHeight; }; const onScroll = () => { stuck = box.scrollHeight - box.scrollTop - box.clientHeight < 16; }; const observer = new MutationObserver(follow); observer.observe(box, { childList: true, subtree: true, characterData: true }); box.addEventListener("scroll", onScroll, { passive: true }); follow(); return () => { observer.disconnect(); box.removeEventListener("scroll", onScroll); }; }, [pinned]); return ( <div ref={ref} className={cn("pt-0.5 pb-3 text-[13px] leading-relaxed text-zinc-500 dark:text-zinc-400", className)} > {children} </div> ); } /** * Opens and closes its content by animating height. Clipped only while it * moves, so focus rings and the rail can spill out at rest. At rest an open * fold is `auto` and follows its content at once; a `smooth` one stays on a * number and springs after it instead, for content that streams in. */ function Fold({ open, appear = false, smooth = false, reduced, as = "div", id, className, children, }: { open: boolean; /** Grow in from nothing on mount. */ appear?: boolean; smooth?: boolean; reduced: boolean; as?: "div" | "li"; id?: string; className?: string; children: React.ReactNode; }) { const innerRef = React.useRef<HTMLDivElement>(null); const shown = open && !appear; const height = useMotionValue<number | "auto">(shown ? "auto" : 0); const overflow = useMotionValue(shown ? "visible" : "hidden"); const opacity = useMotionValue(shown ? 1 : 0); const y = useTransform(opacity, [0, 1], [-TRAIL.drift, 0]); const state = React.useRef({ open: false, mounted: false, moving: false, target: -1, reduced, smooth }); React.useLayoutEffect(() => { state.current.reduced = reduced; state.current.smooth = smooth; }); // Animate to `to`, clipped on the way. const glide = React.useCallback( (to: number, transition: Transition) => { const s = state.current; const inner = innerRef.current; if (!inner || (s.moving && s.target === to)) return; s.target = to; if (height.get() === "auto") height.jump(inner.offsetHeight); // Nothing to animate: settle where it is. if (s.reduced || (!s.moving && height.get() === to)) { s.moving = false; height.jump(to === 0 || s.smooth ? to : "auto"); overflow.set(to === 0 ? "hidden" : "visible"); return; } s.moving = true; overflow.set("hidden"); animate(height, to, { ...transition, onComplete: () => { if (s.target !== to) return; s.moving = false; if (to === 0) return; if (!s.smooth) height.jump("auto"); overflow.set("visible"); }, }); }, [height, overflow], ); React.useLayoutEffect(() => { const s = state.current; const inner = innerRef.current; if (!inner) return; const first = !s.mounted; s.mounted = true; s.open = open; if (first && !(open && appear)) { // Already at rest; a smooth fold swaps `auto` for its number. if (open && smooth) { s.target = inner.offsetHeight; height.jump(s.target); } return; } glide(open ? inner.offsetHeight : 0, TRAIL.fold); if (s.reduced) opacity.jump(open ? 1 : 0); else animate(opacity, open ? 1 : 0, open ? TRAIL.show : TRAIL.hide); // Mount-time values only: `appear` and `smooth` are fixed per fold. // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, glide, height, opacity]); // Content that changes size: a fold on the move retargets, a smooth one follows. React.useLayoutEffect(() => { const inner = innerRef.current; if (!inner) return; const observer = new ResizeObserver(() => { const s = state.current; if (!s.open || !(s.moving || s.smooth)) return; glide(inner.offsetHeight, s.moving && !s.smooth ? TRAIL.fold : TRAIL.grow); }); observer.observe(inner); return () => observer.disconnect(); }, [glide]); const content = ( <motion.div ref={innerRef} className="flow-root" style={{ opacity, y: reduced ? 0 : y }}> {children} </motion.div> ); const style = { height, overflow } as { height: MotionValue<number | "auto">; overflow: MotionValue<string> }; return as === "li" ? ( <motion.li id={id} inert={!open} className={className} style={style}> {content} </motion.li> ) : ( <motion.div id={id} inert={!open} className={className} style={style}> {content} </motion.div> ); }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
Examples
Finished sections built with Thought Trail. Each one installs whole, with the component included.
Chat Reasoning
A chat reply that thinks first. The message's parts are shaped like the AI SDK's, and each reasoning or tool part becomes a step. The trail folds into "Thought for 9s" as soon as the answer starts streaming below it.
React help
Why does my useEffect run twice in development?
"use client";
import * as React from "react";
import { useInView } from "motion/react";
import { ThoughtTrail, ThoughtTrailStep } from "@/components/ui/thought-trail";
/* Parts shaped like the AI SDK's UIMessage parts. With useChat, drop the
* script below and render:
*
* <ReasoningTrail parts={message.parts} streaming={status === "streaming"} />
*/
type ToolState = "input-streaming" | "input-available" | "output-available" | "output-error";
type Page = { title: string; path: string };
type Part =
| { type: "reasoning"; text: string; state: "streaming" | "done" }
| {
type: "tool-searchDocs";
toolCallId: string;
state: ToolState;
input: { query: string };
output?: { pages: Page[] };
}
| {
type: "tool-readPage";
toolCallId: string;
state: ToolState;
input: { path: string };
output?: { title: string; excerpt: string };
}
| { type: "text"; text: string; state: "streaming" | "done" };
/** Every part but the answer becomes a step. The last one works until the next begins, as it does in a stream. */
function ReasoningTrail({ parts, streaming }: { parts: Part[]; streaming: boolean }) {
const steps = parts.filter((part) => part.type !== "text");
const answering = parts.some((part) => part.type === "text");
return (
<ThoughtTrail
running={streaming && !answering}
label="Thinking"
doneLabel={({ seconds }) => (seconds ? `Thought for ${seconds}s` : "Thought")}
icon={<Sparkle />}
>
{steps.map((part, i) => {
const last = i === steps.length - 1 && streaming && !answering;
const failed = "toolCallId" in part && part.state === "output-error";
const status = failed ? "error" : last ? "active" : "done";
return <PartStep key={"toolCallId" in part ? part.toolCallId : `reasoning-${i}`} part={part} status={status} />;
})}
</ThoughtTrail>
);
}
function PartStep({ part, status }: { part: Part; status: "active" | "done" | "error" }) {
switch (part.type) {
case "reasoning": {
// Reasoning summaries open with a bold heading; it names the step.
const [, heading, body] = part.text.match(/^\*\*(.+?)\*\*\s*([\s\S]*)$/) ?? [null, "Thinking", part.text];
return (
<ThoughtTrailStep status={status} title={heading}>
{body ? <p className="text-pretty">{body}</p> : null}
</ThoughtTrailStep>
);
}
case "tool-searchDocs":
return (
<ThoughtTrailStep
status={status}
title="Searching the React docs"
summary={part.output ? `Found ${part.output.pages.length} pages` : "Search failed"}
icon={<SearchIcon />}
>
<p className="text-zinc-400 dark:text-zinc-500">“{part.input.query}”</p>
{part.output ? (
<ul className="mt-1.5 space-y-1">
{part.output.pages.map((page) => (
<li key={page.path} className="flex min-w-0 items-baseline gap-2">
<span className="shrink-0 text-zinc-700 dark:text-zinc-300">{page.title}</span>
<span className="truncate font-mono text-[11px] text-zinc-400 dark:text-zinc-500">{page.path}</span>
</li>
))}
</ul>
) : null}
</ThoughtTrailStep>
);
case "tool-readPage":
return (
<ThoughtTrailStep
status={status}
title={`Reading react.dev${part.input.path}`}
summary={part.output ? `Read “${part.output.title}”` : "Couldn't read the page"}
icon={<PageIcon />}
>
{part.output ? (
<blockquote className="border-l-2 border-zinc-200 pl-3 text-pretty text-zinc-600 dark:border-zinc-700 dark:text-zinc-400">
{part.output.excerpt}
</blockquote>
) : (
<p className="font-mono text-[11px] text-zinc-400 dark:text-zinc-500">GET react.dev{part.input.path}</p>
)}
</ThoughtTrailStep>
);
default:
return null;
}
}
/* ── The scripted reply ─────────────────────────────────── */
const question = "Why does my useEffect run twice in development?";
const thoughts = [
"**Considering Strict Mode**\n\nAn effect that fires twice, but only in development, is almost always Strict Mode remounting the component. Worth checking the docs for the exact behavior before answering.",
"**Drafting the answer**\n\nConfirmed: development only, setup → cleanup → setup. The fix is a cleanup function, not turning Strict Mode off. A connection makes the clearest example.",
];
const pages: Page[] = [
{ title: "<StrictMode>", path: "/reference/react/StrictMode" },
{ title: "Synchronizing with Effects", path: "/learn/synchronizing-with-effects" },
{ title: "useEffect", path: "/reference/react/useEffect" },
{ title: "You Might Not Need an Effect", path: "/learn/you-might-not-need-an-effect" },
];
const answer = [
"That's Strict Mode, and it only happens in development. React mounts each component, unmounts it and mounts it again, so every effect runs setup, cleanup, then setup. It's there to catch effects that don't clean up after themselves.",
"Keep Strict Mode on. Return a cleanup that undoes what the effect did:",
"```useEffect(() => {\n const connection = createConnection(roomId);\n connection.connect();\n return () => connection.disconnect();\n}, [roomId]);",
"With the cleanup in place the second run is harmless, and production runs the effect once.",
];
// When each part starts and finishes, in ms.
const T = {
think: [600, 3300],
search: [3300, 4800], // the tool returns at 4.8s; the step works until the next part
read: [5600, 7000],
draft: [7800, 9700],
answer: [9900, 13900],
};
const words = (text: string, share: number) => {
const all = text.split(" ");
return all.slice(0, Math.ceil(all.length * Math.min(1, Math.max(0, share)))).join(" ");
};
const share = (t: number, [from, to]: number[]) => (t - from) / (to - from);
/** The message's parts at `t` ms. */
function partsAt(t: number): Part[] {
const parts: Part[] = [];
const reasoning = (text: string, span: number[]) => {
if (t < span[0]) return;
const done = t >= span[1];
// The heading lands whole, then the body streams.
const [heading, body] = text.split("\n\n");
parts.push({ type: "reasoning", text: `${heading}\n\n${words(body, share(t, span) * 1.1)}`, state: done ? "done" : "streaming" });
};
reasoning(thoughts[0], T.think);
if (t >= T.search[0]) {
parts.push({
type: "tool-searchDocs",
toolCallId: "call_search",
state: t >= T.search[1] ? "output-available" : "input-available",
input: { query: "useEffect runs twice in development" },
output: t >= T.search[1] ? { pages } : undefined,
});
}
if (t >= T.read[0]) {
parts.push({
type: "tool-readPage",
toolCallId: "call_read",
state: t >= T.read[1] ? "output-available" : "input-available",
input: { path: "/reference/react/StrictMode" },
output:
t >= T.read[1]
? {
title: "<StrictMode>",
excerpt:
"Your components will re-run Effects an extra time to find bugs caused by missing Effect cleanup.",
}
: undefined,
});
}
reasoning(thoughts[1], T.draft);
if (t >= T.answer[0]) {
const chars = answer.join("\n\n").length * Math.min(1, share(t, T.answer));
let left = Math.ceil(chars);
const shown: string[] = [];
for (const block of answer) {
if (left <= 0) break;
shown.push(block.slice(0, left));
left -= block.length + 2;
}
parts.push({ type: "text", text: shown.join("\n\n"), state: t >= T.answer[1] ? "done" : "streaming" });
}
return parts;
}
/** Milliseconds into the current run, from when the demo first scrolls into view. Stops at `end`. */
function usePlayhead(run: number, playing: boolean, end: number) {
const [clock, setClock] = React.useState({ run, t: 0 });
React.useEffect(() => {
if (!playing) return;
const start = performance.now();
const timer = window.setInterval(() => {
const t = Math.min(end, performance.now() - start);
setClock({ run, t });
if (t >= end) window.clearInterval(timer);
}, 50);
return () => window.clearInterval(timer);
}, [run, playing, end]);
return clock.run === run ? clock.t : 0;
}
/** Keeps a scrolling box at its bottom while its content grows, until the reader scrolls up. */
function useStickToBottom(ref: React.RefObject<HTMLDivElement | null>) {
React.useEffect(() => {
const box = ref.current;
const content = box?.firstElementChild;
if (!box || !content) return;
let stuck = true;
const onScroll = () => {
stuck = box.scrollHeight - box.scrollTop - box.clientHeight < 24;
};
const observer = new ResizeObserver(() => {
if (stuck) box.scrollTop = box.scrollHeight;
});
observer.observe(content);
box.addEventListener("scroll", onScroll, { passive: true });
return () => {
observer.disconnect();
box.removeEventListener("scroll", onScroll);
};
}, [ref]);
}
// A chat reply that thinks first. The trail opens under the question, folds
// into "Thought for 9s" once the answer starts, and the answer streams below.
export function ThoughtTrailChatDemo() {
const ref = React.useRef<HTMLDivElement>(null);
const scrollRef = React.useRef<HTMLDivElement>(null);
const playing = useInView(ref, { once: true, amount: 0.4 });
const [run, setRun] = React.useState(0);
const t = usePlayhead(run, playing, T.answer[1]);
const parts = playing ? partsAt(t) : [];
const text = parts.find((part) => part.type === "text");
useStickToBottom(scrollRef);
return (
<div
ref={ref}
className="flex h-[34rem] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm shadow-zinc-950/5 dark:border-zinc-800 dark:bg-zinc-950 dark:shadow-black/40"
>
<div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-zinc-100 px-4 dark:border-zinc-800/80">
<p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">React help</p>
<button
type="button"
onClick={() => setRun((r) => r + 1)}
className="inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-xs font-medium text-zinc-600 transition-[background-color,color,scale] duration-150 outline-none select-none hover:bg-zinc-100 hover:text-zinc-950 focus-visible:ring-2 focus-visible:ring-zinc-400 active:scale-[0.96] dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 dark:focus-visible:ring-zinc-600"
>
Ask again
</button>
</div>
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
<div className="flex flex-col gap-5 px-4 py-5 sm:px-6">
<p className="max-w-[80%] self-end rounded-2xl rounded-br-md bg-zinc-100 px-3.5 py-2 text-sm text-zinc-900 dark:bg-zinc-800 dark:text-zinc-100">
{question}
</p>
{playing ? (
<div className="min-w-0">
<ReasoningTrail parts={parts} streaming={t < T.answer[1]} />
{text && text.type === "text" ? <Answer text={text.text} /> : null}
</div>
) : null}
</div>
</div>
<div className="shrink-0 border-t border-zinc-100 p-3 dark:border-zinc-800/80">
<div className="flex h-10 items-center rounded-xl border border-zinc-200 px-3.5 text-sm text-zinc-400 dark:border-zinc-800 dark:text-zinc-500">
Ask a follow-up…
</div>
</div>
</div>
);
}
function Answer({ text }: { text: string }) {
return (
<div className="mt-3 space-y-3 text-sm leading-relaxed text-pretty text-zinc-800 dark:text-zinc-200">
{text.split("\n\n").map((block, i) =>
block.startsWith("```") ? (
<pre
key={i}
className="overflow-x-auto rounded-lg border border-zinc-200 bg-zinc-50 px-3.5 py-2.5 font-mono text-[12px] leading-5 text-zinc-700 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-300"
>
{block.slice(3)}
</pre>
) : (
<p key={i}>{block}</p>
),
)}
</div>
);
}
function Sparkle() {
return (
<svg viewBox="0 0 16 16" fill="none">
<path
d="M8 1.75c.35 2.9 1.35 3.9 4.25 4.25-2.9.35-3.9 1.35-4.25 4.25C7.65 7.35 6.65 6.35 3.75 6 6.65 5.65 7.65 4.65 8 1.75Z M12.5 10.5c.15 1.2.55 1.6 1.75 1.75-1.2.15-1.6.55-1.75 1.75-.15-1.2-.55-1.6-1.75-1.75 1.2-.15 1.6-.55 1.75-1.75Z"
fill="currentColor"
/>
</svg>
);
}
function SearchIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden>
<circle cx="7" cy="7" r="4.25" stroke="currentColor" strokeWidth="1.5" />
<path d="M10.25 10.25 13.5 13.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
function PageIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden>
<path d="M4 1.75h5L12.25 5v9.25H4z M9 1.75V5h3.25" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" />
</svg>
);
}
Deploy Log
A CI pipeline with every step listed up front as pending. Logs stream into the running step, pinned to the newest line, and each step keeps its time. Turn on Break a test: the run stops at the tests, which turn red and stay open, and the steps after them never start.
acme/storefront
main8f3c2a1 · Fix cart badge overflow
"use client";
import * as React from "react";
import { useInView } from "motion/react";
import { cn } from "@/lib/utils";
import { ThoughtTrail, ThoughtTrailStep } from "@/components/ui/thought-trail";
type Stage = {
id: string;
title: string;
summary: string;
ms: number;
log: string[];
/** With "Break a test" on: the log it stops on, and its summary. */
fails?: { log: string[]; summary: string };
};
const stages: Stage[] = [
{
id: "clone",
title: "Cloning repository",
summary: "Cloned 8f3c2a1",
ms: 1400,
log: [
"Cloning github.com/acme/storefront (branch: main)",
"HEAD is now at 8f3c2a1 Fix cart badge overflow",
"Cloned in 1.1s",
],
},
{
id: "install",
title: "Installing dependencies",
summary: "Installed 1,284 packages",
ms: 3000,
log: [
"$ npm ci",
"npm warn deprecated inflight@1.0.6",
"added 1284 packages in 2.7s",
"214 packages are looking for funding",
"found 0 vulnerabilities",
],
},
{
id: "build",
title: "Building",
summary: "Built 42 routes",
ms: 4200,
log: [
"$ next build",
"▲ Next.js 16.4.0",
"Creating an optimized production build …",
"✓ Compiled successfully in 2.9s",
"✓ Linting and checking validity of types",
"✓ Collecting page data",
"✓ Generating static pages (42/42)",
"Route (app) Size First Load JS",
"┌ ○ / 4.1 kB 118 kB",
"├ ○ /cart 2.7 kB 116 kB",
"├ ● /products/[slug] 3.4 kB 117 kB",
"└ ○ /checkout 5.2 kB 119 kB",
],
},
{
id: "test",
title: "Running tests",
summary: "212 tests passed",
ms: 2800,
log: [
"$ vitest run",
" ✓ cart/badge.test.ts (12)",
" ✓ cart/totals.test.ts (31)",
" ✓ checkout/address.test.ts (24)",
" ✓ products/search.test.ts (58)",
" Test Files 38 passed (38)",
" Tests 212 passed (212)",
],
fails: {
summary: "1 of 212 tests failed",
log: [
"$ vitest run",
" ✓ cart/totals.test.ts (31)",
" ✗ cart/badge.test.ts > caps the count at 99+",
' Expected: "99+"',
' Received: "100"',
" Test Files 1 failed | 37 passed (38)",
" Tests 1 failed | 211 passed (212)",
],
},
},
{
id: "deploy",
title: "Deploying to production",
summary: "Live at storefront.acme.app",
ms: 2200,
log: [
"Uploading build output (18.4 MB)",
"Promoting to production",
"Assigning storefront.acme.app",
"✓ Ready in 2.1s",
],
},
];
const LEAD = 700; // ms the pipeline sits queued before the first step
const starts = stages.map((_, i) => LEAD + stages.slice(0, i).reduce((sum, s) => sum + s.ms, 0));
const END = LEAD + stages.reduce((sum, s) => sum + s.ms, 0);
const testIndex = stages.findIndex((s) => s.fails);
const FAIL_AT = starts[testIndex] + stages[testIndex].ms; // the broken run stops here
/** Milliseconds into the current run, from when the demo first scrolls into view. Stops at `end`. */
function usePlayhead(run: number, playing: boolean, end: number) {
const [clock, setClock] = React.useState({ run, t: 0 });
React.useEffect(() => {
if (!playing) return;
const start = performance.now();
const timer = window.setInterval(() => {
const t = Math.min(end, performance.now() - start);
setClock({ run, t });
if (t >= end) window.clearInterval(timer);
}, 50);
return () => window.clearInterval(timer);
}, [run, playing, end]);
return clock.run === run ? clock.t : 0;
}
const seconds = (ms: number) => `${(ms / 1000).toFixed(1)}s`;
// A CI pipeline with every step listed up front. Logs stream into the step
// that's running and stay pinned to the newest line; each step keeps its
// time. Turn on "Break a test" and the run stops at the tests, with the
// failure left open.
export function ThoughtTrailDeployDemo() {
const ref = React.useRef<HTMLDivElement>(null);
const playing = useInView(ref, { once: true, amount: 0.4 });
const [broken, setBroken] = React.useState(false);
const [run, setRun] = React.useState(0);
const end = broken ? FAIL_AT : END;
const t = usePlayhead(run, playing, end);
const redeploy = (nextBroken = broken) => {
setBroken(nextBroken);
setRun((r) => r + 1);
};
return (
<div
ref={ref}
className="flex h-[32rem] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm shadow-zinc-950/5 dark:border-zinc-800 dark:bg-zinc-950 dark:shadow-black/40"
>
<div className="flex flex-wrap items-center gap-x-4 gap-y-3 border-b border-zinc-100 px-5 py-3.5 dark:border-zinc-800/80">
<div className="flex min-w-0 flex-1 items-center gap-3">
<span
aria-hidden
className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-zinc-900 text-sm font-semibold text-white dark:bg-zinc-100 dark:text-zinc-900"
>
a
</span>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">acme/storefront</p>
<p className="flex min-w-0 items-center gap-1.5 text-xs text-zinc-500 dark:text-zinc-400">
<span className="rounded bg-zinc-100 px-1 font-mono text-[11px] text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
main
</span>
<span className="truncate">8f3c2a1 · Fix cart badge overflow</span>
</p>
</div>
</div>
<div className="flex items-center gap-1">
<label className="inline-flex h-8 cursor-pointer items-center gap-2 rounded-lg px-2.5 text-xs font-medium text-zinc-600 select-none hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800">
<input
type="checkbox"
checked={broken}
onChange={(e) => redeploy(e.target.checked)}
className="peer sr-only"
/>
<span
aria-hidden
className="relative h-4 w-7 rounded-full bg-zinc-200 transition-colors duration-200 peer-checked:bg-rose-500 peer-focus-visible:ring-2 peer-focus-visible:ring-zinc-400 dark:bg-zinc-700 dark:peer-checked:bg-rose-500 dark:peer-focus-visible:ring-zinc-600"
>
<span
className={cn(
"absolute top-0.5 left-0.5 size-3 rounded-full bg-white shadow-sm transition-transform duration-200",
broken && "translate-x-3",
)}
/>
</span>
Break a test
</label>
<button
type="button"
onClick={() => redeploy()}
className="inline-flex h-8 items-center rounded-lg px-2.5 text-xs font-medium text-zinc-600 transition-[background-color,color,scale] duration-150 outline-none select-none hover:bg-zinc-100 hover:text-zinc-950 focus-visible:ring-2 focus-visible:ring-zinc-400 active:scale-[0.96] dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 dark:focus-visible:ring-zinc-600"
>
Redeploy
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{playing ? (
<ThoughtTrail
running={t < end}
label="Deploying"
collapseOnDone={false}
doneLabel={({ seconds, failed }) => `${failed ? "Failed after" : "Deployed in"} ${seconds ?? 0}s`}
>
{stages.map((stage, i) => {
const start = starts[i];
const fails = broken && stage.fails;
const ms = stage.ms;
// A broken run never reaches the steps after the tests.
const skipped = broken && i > testIndex;
const status =
skipped || t < start ? "pending" : t < start + ms ? "active" : fails ? "error" : "done";
const log = fails ? stage.fails!.log : stage.log;
const shown = Math.ceil(Math.min(1, (t - start) / (ms * 0.9)) * log.length);
return (
<ThoughtTrailStep
key={stage.id}
status={status}
title={stage.title}
summary={fails ? stage.fails!.summary : stage.summary}
meta={status === "pending" ? undefined : seconds(Math.min(t - start, ms))}
contentClassName="max-h-32 overflow-y-auto pt-3 [mask-image:linear-gradient(to_bottom,transparent,#000_0.75rem)]"
>
<pre className="font-mono text-[11.5px] leading-5 whitespace-pre">
{log.slice(0, shown).map((line, j) => (
<span
key={j}
className={cn(
"block",
line.includes("✓") && "text-emerald-700 dark:text-emerald-400",
(line.includes("✗") || line.includes("failed") || line.includes("Received")) &&
"text-rose-600 dark:text-rose-400",
)}
>
{line}
</span>
))}
</pre>
</ThoughtTrailStep>
);
})}
</ThoughtTrail>
) : null}
</div>
</div>
);
}
Research Agent
Steps with their own icons. Sources collect as chips while the agent searches, pages tick off as it reads, and the answer cites what it found once the trail folds away. Open Found 9 sources to see them again.
Which vector database should we use for 10M embeddings?
"use client";
import * as React from "react";
import { AnimatePresence, motion, useInView } from "motion/react";
import { cn } from "@/lib/utils";
import { ThoughtTrail, ThoughtTrailStep } from "@/components/ui/thought-trail";
const question = "Which vector database should we use for 10M embeddings?";
type Source = { domain: string; tint: string };
// Monograms instead of favicons, so the demo needs no network.
const sources: Source[] = [
{ domain: "github.com", tint: "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900" },
{ domain: "qdrant.tech", tint: "bg-rose-500/15 text-rose-700 dark:text-rose-300" },
{ domain: "pinecone.io", tint: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300" },
{ domain: "news.ycombinator.com", tint: "bg-orange-500/15 text-orange-700 dark:text-orange-300" },
{ domain: "supabase.com", tint: "bg-teal-500/15 text-teal-700 dark:text-teal-300" },
{ domain: "weaviate.io", tint: "bg-lime-500/15 text-lime-700 dark:text-lime-300" },
{ domain: "timescale.com", tint: "bg-amber-500/15 text-amber-700 dark:text-amber-300" },
{ domain: "ann-benchmarks.com", tint: "bg-sky-500/15 text-sky-700 dark:text-sky-300" },
{ domain: "aws.amazon.com", tint: "bg-violet-500/15 text-violet-700 dark:text-violet-300" },
];
const searches = [
"pgvector vs dedicated vector databases at 10M rows",
"HNSW memory use for 1536-dimension embeddings",
"Filtered vector search at high query rates",
];
const reads = [
{ title: "HNSW vs IVFFlat indexes in pgvector", source: 0 },
{ title: "Payload indexes and filtered search", source: 1 },
{ title: "Choosing an index for tens of millions of vectors", source: 2 },
{ title: "Ask HN: Running pgvector in production?", source: 3 },
];
const comparison =
"Ten million 1536-dimension vectors is about 60 GB of floats before the index, which still fits one large Postgres instance. pgvector keeps vectors next to the rows they describe, so there's no second system to sync. Qdrant and Pinecone pull ahead when filters get heavy at high query rates, or when the index outgrows one machine.";
type Phase = { title: string; summary: string; ms: number; icon: React.ReactNode };
const phases: Phase[] = [
{ title: "Planning the search", summary: "Planned 3 searches", ms: 2200, icon: <ListIcon /> },
{ title: "Searching the web", summary: `Found ${sources.length} sources`, ms: 3000, icon: <GlobeIcon /> },
{ title: `Reading ${reads.length} pages`, summary: `Read ${reads.length} pages`, ms: 3600, icon: <PageIcon /> },
{ title: "Comparing the options", summary: "Compared 3 options", ms: 3400, icon: <ScaleIcon /> },
];
const LEAD = 600;
const starts = phases.map((_, i) => LEAD + phases.slice(0, i).reduce((sum, p) => sum + p.ms, 0));
const END = LEAD + phases.reduce((sum, p) => sum + p.ms, 0);
/** Milliseconds into the current run, from when the demo first scrolls into view. Stops at `end`. */
function usePlayhead(run: number, playing: boolean, end: number) {
const [clock, setClock] = React.useState({ run, t: 0 });
React.useEffect(() => {
if (!playing) return;
const start = performance.now();
const timer = window.setInterval(() => {
const t = Math.min(end, performance.now() - start);
setClock({ run, t });
if (t >= end) window.clearInterval(timer);
}, 50);
return () => window.clearInterval(timer);
}, [run, playing, end]);
return clock.run === run ? clock.t : 0;
}
// A research agent. Sources collect as chips while it searches, pages tick
// off as it reads them, and once it's done the trail folds away and the
// answer cites what it found. Open "Found 9 sources" to see them all again.
export function ThoughtTrailResearchDemo() {
const ref = React.useRef<HTMLDivElement>(null);
const playing = useInView(ref, { once: true, amount: 0.4 });
const [run, setRun] = React.useState(0);
const t = usePlayhead(run, playing, END + 300);
const finished = t >= END;
return (
<div
ref={ref}
className="flex h-[34rem] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm shadow-zinc-950/5 dark:border-zinc-800 dark:bg-zinc-950 dark:shadow-black/40"
>
<div className="flex items-start gap-4 border-b border-zinc-100 px-5 py-4 dark:border-zinc-800/80">
<div className="min-w-0 flex-1">
<span className="inline-flex items-center gap-1 rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
<span aria-hidden className="size-3 [&_svg]:size-3">
<GlobeIcon />
</span>
Deep research
</span>
<p className="mt-2 text-[15px] leading-snug font-medium text-pretty text-zinc-900 dark:text-zinc-100">
{question}
</p>
</div>
<button
type="button"
onClick={() => setRun((r) => r + 1)}
className="inline-flex h-8 shrink-0 items-center rounded-lg px-2.5 text-xs font-medium text-zinc-600 transition-[background-color,color,scale] duration-150 outline-none select-none hover:bg-zinc-100 hover:text-zinc-950 focus-visible:ring-2 focus-visible:ring-zinc-400 active:scale-[0.96] dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 dark:focus-visible:ring-zinc-600"
>
Run again
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{playing ? (
<ThoughtTrail
running={!finished}
label="Researching"
doneLabel={({ seconds }) => `Researched for ${seconds ?? 0}s · ${sources.length} sources`}
>
{phases.map((phase, i) => {
if (t < starts[i]) return null;
const progress = Math.min(1, (t - starts[i]) / (phase.ms * 0.85));
const done = t >= starts[i] + phase.ms;
return (
<ThoughtTrailStep
key={phase.title}
status={done ? "done" : "active"}
title={phase.title}
summary={phase.summary}
icon={phase.icon}
meta={i === 1 ? `${Math.ceil(progress * sources.length)}` : undefined}
>
<PhaseDetails index={i} progress={progress} />
</ThoughtTrailStep>
);
})}
</ThoughtTrail>
) : null}
<AnimatePresence>
{finished ? (
<motion.div
key={run}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut", delay: 0.2 }}
className="mt-4 space-y-3 text-sm leading-relaxed text-pretty text-zinc-800 dark:text-zinc-200"
>
<p>
<strong className="font-semibold text-zinc-950 dark:text-zinc-50">Start with pgvector</strong> if
you're already on Postgres. At 10M vectors an HNSW index still fits one large instance, and you
keep a single database to run and back up. <Cite n={1} /> <Cite n={4} />
</p>
<p>
Move to a dedicated engine like Qdrant when filtered queries at high traffic start to dominate, or
the index needs to shard across machines. <Cite n={2} /> <Cite n={3} />
</p>
</motion.div>
) : null}
</AnimatePresence>
</div>
</div>
);
}
function PhaseDetails({ index, progress }: { index: number; progress: number }) {
if (index === 0) {
return (
<ul className="space-y-1">
{searches.slice(0, Math.ceil(progress * searches.length)).map((query) => (
<li key={query} className="flex items-center gap-2">
<span aria-hidden className="size-3 shrink-0 text-zinc-400 dark:text-zinc-500 [&_svg]:size-3">
<SearchIcon />
</span>
<span className="truncate">{query}</span>
</li>
))}
</ul>
);
}
if (index === 1) {
return (
<div className="flex flex-wrap gap-1.5">
{sources.slice(0, Math.ceil(progress * sources.length)).map((source) => (
<motion.span
key={source.domain}
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", visualDuration: 0.25, bounce: 0.3 }}
className="inline-flex items-center gap-1.5 rounded-full border border-zinc-200 bg-white py-0.5 pr-2 pl-0.5 text-xs text-zinc-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400"
>
<Monogram source={source} />
{source.domain}
</motion.span>
))}
</div>
);
}
if (index === 2) {
return (
<ul className="space-y-1.5">
{reads.map((page, i) => {
// Pages are read one after another across the step.
const read = progress >= (i + 1) / reads.length;
const reading = !read && progress >= i / reads.length;
return (
<li
key={page.title}
className={cn(
"flex min-w-0 items-center gap-2 transition-opacity duration-300",
!read && !reading && "opacity-40",
)}
>
<Monogram source={sources[page.source]} />
<span className={cn("truncate", reading && "text-zinc-900 dark:text-zinc-100")}>{page.title}</span>
<span className="ml-auto shrink-0 text-xs text-zinc-400 dark:text-zinc-500">
{read ? "Read" : reading ? "Reading…" : ""}
</span>
</li>
);
})}
</ul>
);
}
const words = comparison.split(" ");
return <p className="text-pretty">{words.slice(0, Math.ceil(progress * words.length)).join(" ")}</p>;
}
function Monogram({ source }: { source: Source }) {
return (
<span
aria-hidden
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold uppercase",
source.tint,
)}
>
{source.domain[0]}
</span>
);
}
function Cite({ n }: { n: number }) {
const source = sources[reads[n - 1].source];
return (
<span
title={source.domain}
className="inline-flex h-4 min-w-4 -translate-y-px items-center justify-center rounded bg-zinc-100 px-1 align-middle text-[10px] font-medium text-zinc-500 tabular-nums dark:bg-zinc-800 dark:text-zinc-400"
>
{n}
</span>
);
}
function GlobeIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden>
<circle cx="8" cy="8" r="6.25" stroke="currentColor" strokeWidth="1.4" />
<path d="M1.75 8h12.5M8 1.75c1.8 1.7 2.6 3.8 2.6 6.25S9.8 12.55 8 14.25C6.2 12.55 5.4 10.45 5.4 8S6.2 3.45 8 1.75Z" stroke="currentColor" strokeWidth="1.4" />
</svg>
);
}
function SearchIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden>
<circle cx="7" cy="7" r="4.25" stroke="currentColor" strokeWidth="1.5" />
<path d="M10.25 10.25 13.5 13.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
function ListIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden>
<path d="M6 4h7.5M6 8h7.5M6 12h7.5M2.75 4h.5M2.75 8h.5M2.75 12h.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
function PageIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden>
<path d="M4 1.75h5L12.25 5v9.25H4z M9 1.75V5h3.25" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" />
</svg>
);
}
function ScaleIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M8 2v12M4.5 14h7M3 4.5h10M3 4.5 1.25 9a2 2 0 0 0 3.5 0L3 4.5Zm10 0L11.25 9a2 2 0 0 0 3.5 0L13 4.5Z"
stroke="currentColor"
strokeWidth="1.3"
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
running | boolean | false | The agent is working: the header shimmers and counts seconds. When it turns false the trail finishes. The header rolls to its done label and, with collapseOnDone, the trail folds into it a beat later. Turning it true again starts a new run and opens the trail. |
label | ReactNode | "Working" | Header text while running, e.g. Thinking. If the reader folds the trail mid-run, the current step's title follows it. |
doneLabel | (info: { seconds, steps, failed }) => ReactNode | None | Header text once finished. Defaults to Worked for 14s · 5 steps, or Stopped after 9s · 3 steps when a step failed. seconds comes from duration or the trail's own clock and is null when neither knows; steps counts steps that started. |
duration | number | None | Seconds the run took, for a trail restored from history, where the trail never saw it run. Overrides the trail's own clock in doneLabel. |
collapseOnDone | boolean | true | Fold the trail into its header 0.8s after it finishes. A failed run stays open, and so does a trail the reader opened or folded during the run. |
defaultOpen | boolean | running || !collapseOnDone | Open on mount. By default a running trail starts open and a finished one from history starts folded. |
open | boolean | None | Control the fold yourself. The trail then never opens or folds on its own; update it from onOpenChange. |
onOpenChange | (open: boolean) => void | None | Called with the new state when the reader clicks the header. |
icon | ReactNode | None | Shown before the header text, in the same column as the step dots, so the label lines up with the step titles. |
className | string | None | Extra classes for the trail. Other div props pass through. It renders the header and an <ol> of steps; every step must be a direct child of ThoughtTrail. |
step.status | "pending" | "active" | "done" | "error" | "done" | ThoughtTrailStep: where the step is. pending is a dimmed title with a hollow dot, for steps listed before they run. active shimmers and shows its details. done folds them and shows summary with a check, and the rail fills down to the next step. error turns red with a cross, shakes once and keeps its details open. Every change rolls the line to its new text. |
step.titleRequired | ReactNode | None | ThoughtTrailStep: what the step is doing, e.g. Searching the docs. One line, truncated. While active, a band of light sweeps across it in its own color. |
step.summary | ReactNode | None | ThoughtTrailStep: what it did, e.g. Read 4 pages. Replaces the title once the step is done or failed. Without it, the title stays. |
step.icon | ReactNode | None | ThoughtTrailStep: replaces the dot and the check. While active, the working arc circles it; a failure still shows the cross. SVGs are sized to 14px. |
step.meta | ReactNode | None | ThoughtTrailStep: small text at the end of the row, like a duration or a count. |
step.children | ReactNode | None | ThoughtTrailStep: the details, like streamed text, logs or sources. Open while the step is active or failed, folded once it's done, and the reader can open or close them at any time; a step they opened stays open. As content streams in, the box grows on a spring rather than in jumps. |
step.contentClassName | string | None | ThoughtTrailStep: classes for the details box. Give it a max height (max-h-32 overflow-y-auto) and, while the step is active, it stays scrolled to the newest line unless the reader scrolls up. |
step.className | string | None | ThoughtTrailStep: classes for the step's <li>. |
runningType
booleanDefaultfalseThe agent is working: the header shimmers and counts seconds. When it turns false the trail finishes. The header rolls to its done label and, with
collapseOnDone, the trail folds into it a beat later. Turning it true again starts a new run and opens the trail.labelType
ReactNodeDefault"Working"Header text while running, e.g.
Thinking. If the reader folds the trail mid-run, the current step's title follows it.doneLabelType
(info: { seconds, steps, failed }) => ReactNodeHeader text once finished. Defaults to
Worked for 14s · 5 steps, orStopped after 9s · 3 stepswhen a step failed.secondscomes fromdurationor the trail's own clock and isnullwhen neither knows;stepscounts steps that started.durationType
numberSeconds the run took, for a trail restored from history, where the trail never saw it run. Overrides the trail's own clock in
doneLabel.collapseOnDoneType
booleanDefaulttrueFold the trail into its header 0.8s after it finishes. A failed run stays open, and so does a trail the reader opened or folded during the run.
defaultOpenType
booleanDefaultrunning || !collapseOnDoneOpen on mount. By default a running trail starts open and a finished one from history starts folded.
openType
booleanControl the fold yourself. The trail then never opens or folds on its own; update it from
onOpenChange.onOpenChangeType
(open: boolean) => voidCalled with the new state when the reader clicks the header.
iconType
ReactNodeShown before the header text, in the same column as the step dots, so the label lines up with the step titles.
classNameType
stringExtra classes for the trail. Other
divprops pass through. It renders the header and an<ol>of steps; every step must be a direct child ofThoughtTrail.step.statusType
"pending" | "active" | "done" | "error"Default"done"ThoughtTrailStep: where the step is.pendingis a dimmed title with a hollow dot, for steps listed before they run.activeshimmers and shows its details.donefolds them and showssummarywith a check, and the rail fills down to the next step.errorturns red with a cross, shakes once and keeps its details open. Every change rolls the line to its new text.step.titleRequiredType
ReactNodeThoughtTrailStep: what the step is doing, e.g.Searching the docs. One line, truncated. While active, a band of light sweeps across it in its own color.step.summaryType
ReactNodeThoughtTrailStep: what it did, e.g.Read 4 pages. Replaces the title once the step is done or failed. Without it, the title stays.step.iconType
ReactNodeThoughtTrailStep: replaces the dot and the check. While active, the working arc circles it; a failure still shows the cross. SVGs are sized to 14px.step.metaType
ReactNodeThoughtTrailStep: small text at the end of the row, like a duration or a count.step.childrenType
ReactNodeThoughtTrailStep: the details, like streamed text, logs or sources. Open while the step is active or failed, folded once it's done, and the reader can open or close them at any time; a step they opened stays open. As content streams in, the box grows on a spring rather than in jumps.step.contentClassNameType
stringThoughtTrailStep: classes for the details box. Give it a max height (max-h-32 overflow-y-auto) and, while the step is active, it stays scrolled to the newest line unless the reader scrolls up.step.classNameType
stringThoughtTrailStep: classes for the step's<li>.