AI output
Thinking trace
A folding record of what an agent did (steps, reasoning, sources or tool calls), whose header morphs from the running label to the result while the list grows row by row, opens while the agent works and folds away once it is done.
Live preview
Loading preview
Four traces, one per variant: a checklist of five steps that tick off one by one with a running timer, a reasoning trace that settles on "Thought for 7s", a search with a query and four sources, and a tools run with a file read, two edits with line counts and a command that fails. Each one opens while running and folds itself once done, except the failed one. Replay restarts all four.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add thinking-traceUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { ThinkingTrace, type TraceStatus, type TraceStep } from './thinking-trace';
type Frame = { at: number; status: TraceStatus; steps: TraceStep[]; query?: string; more?: number };
function Icon({ d }: { d: string }) {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d={d} stroke="currentColor" strokeWidth="1.33" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
const FILE = (
<Icon d="M9.333 1.667H4.667A1.333 1.333 0 0 0 3.333 3v10a1.333 1.333 0 0 0 1.334 1.333h6.666A1.333 1.333 0 0 0 12.667 13V5L9.333 1.667ZM9.333 1.667V5h3.334" />
);
const PENCIL = (
<Icon d="M8 13.333h6M11 2.333a1.414 1.414 0 0 1 2 2L4.667 12.667 2 13.333l.667-2.666L11 2.333Z" />
);
const TERMINAL = <Icon d="m2.667 11.333 4-4-4-4M8 12.667h5.333" />;
const PLAN = [
'Read the brief',
'Find the open tickets',
'Group them by team',
'Draft the summary',
'Check the numbers',
];
const PLAN_DETAIL = ['3 pages', '18 found', '4 teams', undefined, undefined];
const planAt = (doneCount: number): TraceStep[] =>
PLAN.map((label, i) => ({
label,
detail: i < doneCount ? PLAN_DETAIL[i] : undefined,
status: i < doneCount ? 'done' : i === doneCount ? 'running' : 'pending',
}));
const STEPS: Frame[] = [
...[0, 1, 2, 3, 4].map((n) => ({ at: n * 700, status: 'running' as const, steps: planAt(n) })),
{ at: 3500, status: 'done', steps: planAt(5) },
];
const THOUGHTS = [
'The trip is three days long, so the plan needs one area per day.',
'They arrive at noon on day one, so that day stays close to the station.',
'Rain is likely on day two, so a museum and a covered market fit best.',
];
const REASONING: Frame[] = [
{ at: 0, status: 'running', steps: [] },
...THOUGHTS.map((_, i) => ({
at: 400 + i * 800,
status: 'running' as const,
steps: THOUGHTS.slice(0, i + 1).map((l) => ({ label: l })),
})),
{ at: 2900, status: 'done', steps: THOUGHTS.map((l) => ({ label: l })) },
];
const SOURCES: TraceStep[] = [
{ label: 'Kyoto in three days', detail: 'slowtrips.blog', href: 'https://example.com/kyoto-three-days' },
{
label: 'A calm guide to Higashiyama',
detail: 'lanternguide.com',
href: 'https://example.com/higashiyama',
},
{ label: 'Where to eat near the river', detail: 'rivermaps.org', href: 'https://example.com/river' },
{
label: 'Tea houses that open early',
detail: 'teahouse-notes.net',
href: 'https://example.com/tea-houses',
},
];
const QUERY = 'quiet places to stay in Kyoto';
const sourcesAt = (count: number, runningLast: boolean): TraceStep[] =>
SOURCES.slice(0, count).map((s, i) => ({
...s,
status: runningLast && i === count - 1 ? 'running' : 'done',
}));
const SEARCH: Frame[] = [
{ at: 0, status: 'running', steps: [] },
{ at: 300, status: 'running', steps: [], query: QUERY },
...[1, 2, 3, 4].map((n) => ({
at: 300 + n * 550,
status: 'running' as const,
steps: sourcesAt(n, true),
query: QUERY,
})),
{ at: 2800, status: 'running', steps: sourcesAt(4, false), query: QUERY, more: 4 },
{ at: 3100, status: 'done', steps: sourcesAt(4, false), query: QUERY, more: 4 },
];
const THEME_DIFF = [
{ text: 'export const theme = {' },
{ text: 'radius: 8,', kind: 'remove' as const },
{ text: 'radius: 10,', kind: 'add' as const },
{ text: 'gap: 12,', kind: 'add' as const },
{ text: '};' },
];
const TOOL_ROWS: TraceStep[] = [
{ label: 'Read', argument: 'src/theme.ts', icon: FILE },
{ label: 'Edited', argument: 'src/theme.ts', icon: PENCIL, additions: 2, deletions: 1, output: THEME_DIFF },
{ label: 'Edited', argument: 'src/button.tsx', icon: PENCIL, additions: 12, deletions: 3 },
{ label: 'Ran', argument: 'pnpm test', icon: TERMINAL },
];
const toolsAt = (count: number, last: 'running' | 'done' | 'error'): TraceStep[] =>
TOOL_ROWS.slice(0, count).map((s, i) => ({ ...s, status: i === count - 1 ? last : 'done' }));
const TOOLS: Frame[] = [
{ at: 0, status: 'running', steps: [] },
...[1, 2, 3, 4].flatMap((n) => [
{ at: n * 700 - 400, status: 'running' as const, steps: toolsAt(n, 'running') },
{ at: n * 700, status: 'running' as const, steps: toolsAt(n, n === 4 ? 'running' : 'done') },
]),
{ at: 3600, status: 'error', steps: toolsAt(4, 'error') },
];
const TRACES = [
{ variant: 'steps' as const, frames: STEPS, duration: 9000 },
{ variant: 'reasoning' as const, frames: REASONING, duration: 7000 },
{ variant: 'search' as const, frames: SEARCH, duration: 3000 },
{ variant: 'tools' as const, frames: TOOLS, duration: 8000 },
];
type Shown = Frame & { startedAt?: number; duration?: number; run: number };
const atRest = (): Shown[] =>
TRACES.map((t) => ({ ...(t.frames[t.frames.length - 1] as Frame), duration: t.duration, run: 0 }));
export default function ThinkingTraceDemo({ replay = 0 }: { replay?: number }) {
const [shown, setShown] = React.useState<Shown[]>(atRest);
React.useEffect(() => {
if (replay === 0) return;
const startedAt = Date.now();
const timers = TRACES.flatMap((trace, t) =>
trace.frames.map((frame) =>
window.setTimeout(
() => setShown((list) => list.map((s, i) => (i === t ? { ...frame, startedAt, run: replay } : s))),
frame.at,
),
),
);
return () => {
for (const id of timers) window.clearTimeout(id);
};
}, [replay]);
return (
<div className="flex w-full max-w-[480px] flex-col gap-xl">
{shown.map((trace, i) => (
<ThinkingTrace
// A new key per run, so each replay starts from a fresh first render.
key={`${TRACES[i]?.variant}-${trace.run}`}
variant={TRACES[i]?.variant}
status={trace.status}
steps={trace.steps}
query={trace.query}
more={trace.more}
startedAt={trace.startedAt}
duration={trace.duration}
/>
))}
</div>
);
}
API
Parts: root, header (button), header icon (sparkle, alert or stop icon, same slot), header label (text morph, sweep while running), timer (minutes column, seconds number roll), chevron, live region (visually hidden), panel, guide line, query row (search), step row (div, link or button), tool leading slot (step icon and row chevron, same slot; tools variant), argument chip (tools variant), row detail (opens under the row; tools variant), step mark (pending ring, spinner, check or cross; steps variant), source mark (search variant), step label, step detail, line counts (+ and -; tools variant), row spinner (search and tools), Failed tag, more line.
| Prop | Type | Default | Description |
|---|---|---|---|
| steps | Array<{ label: string; detail?: string; argument?: string; output?: Array<{ text: string; kind?: "add" | "remove" }>; href?: string; icon?: Node; status?: "pending" | "running" | "done" | "error"; additions?: number; deletions?: number }> | - | The rows so far: a step, a sentence of reasoning, a source or a tool call. Append to it as the stream delivers. In the tools variant, argument shows as an inline chip after the label, and output makes the row open a detail block under it. Required. |
| variant | "steps" | "reasoning" | "search" | "tools" | "steps" | How the rows look and what the header says. |
| status | "running" | "done" | "error" | "canceled" | "done" | Where the work is. Running opens the panel; done folds it after a short wait; error keeps it open. |
| label | string | per variant | Header while running. Defaults: "Thinking" (steps, reasoning), "Searching the web" (search), "Running tools" (tools). |
| doneLabel | string | per variant | Header once settled. Defaults: "Thought for" with the time, "Finished thinking", "Searched the web" or "Ran 3 tools". |
| startedAt | number | undefined | Start time in ms. Shows a live timer and times the done label. |
| duration | number | undefined | Run time in ms, when the stream reports it. |
| query | string | undefined | The search query, shown above the sources. |
| more | number | undefined | Number of rows not listed, shown as "+4 more". |
| open | boolean | undefined | Controlled open state of the panel. |
| defaultOpen | boolean | undefined | Open state at first when not controlled; without it the panel follows the status. |
| Event | Description |
|---|---|
| openChange | The person opened or closed the panel. Payload: the new open state. |
| stepClick | The person pressed a row that has no link, for example to see a tool's output. Payload: the step and its index. When there is no listener, rows without a link and without output are not buttons. |
| stepOpenChange | A tools row with output opened or closed its detail block. Payload: the step, its index and the new open state. |
| Slot | Description |
|---|---|
| icon | Replaces the sparkle in the header. |
| stepIcon | Scoped slot for a row mark, for example a site icon in the search variant. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| TTR1 | While running | header label | sweep of light (mask position), repeats | 150% → -50% | duration-loop (1600)ease-linear | No loop |
| TTR2 | Status change | header label | text morph | Thinking → Thought for, Could not finish, Stopped | duration-3xl (460)ease-out | Swaps at once |
| TTR3 | Running to settled | header label | color | text-primary (with sweep) → text-secondary | duration-lg (320)ease-out | Swaps at once |
| TTR4 | Running to settled | header icon | color | fg-secondary → fg-tertiary | duration-sm (200)ease-out | Swaps at once |
| TTR5 | Status to error or canceled, or back | header icons | opacity, blur | 0, blur-swap → 1, 0 (the leaving icon runs back) | duration-md (260)ease-out | Swaps at once |
| TTR6 | Status to error or canceled, or back | header icons | scale | scale-swap → 1 (the leaving icon runs back) | duration-xl (380)ease-out | Swaps at once |
| TTR7 | Timer, minutes column or Failed tag shows or hides | reveal (timer, minutes, Failed tag) | column width (grid columns) | 0fr → 1fr | duration-2xl (420)ease-out | Resizes at once |
| TTR8 | Timer, minutes column or Failed tag shows | reveal (timer, minutes, Failed tag) | opacity, blur | 0, blur-swap → 1, 0 | duration-lg (320)ease-out | Shows at once |
| TTR9 | Timer, minutes column or Failed tag hides | reveal (timer, minutes, Failed tag) | opacity | 1 → 0 | duration-xs (160)ease-out | Hides at once |
| TTR10 | Each whole second while running | timer seconds | number roll up | n s → n + 1 s | duration-4xl (520)ease-out | Swaps at once |
| TTR11 | Open or close | chevron | rotation | 0deg → 180deg | duration-lg (320)ease-out | Swaps at once |
| TTR12 | Open or close | panel | height | 0 → list height | duration-2xl (420)ease-out | Resizes at once |
| TTR13 | Open or close | panel | opacity | 0 → 1 | duration-lg (320)ease-out | Shows at once |
| TTR14 | A row arrives or grows | panel | height | old list height → new list height | duration-2xl (420)ease-out | Resizes at once |
| TTR15 | Running to done (not error) | panel | folds by itself (TTR12 and TTR13) | open → closed | duration-2xl (420)ease-outdelay duration-6xl (900) | Resizes at once |
| TTR16 | First render with rows | step row | opacity, translate y | 0, distance-rise → 1, 0 | duration-lg (320)ease-settledelay 90 x row | Shows at once |
| TTR17 | A row arrives while streaming | step row | opacity, translate y | 0, distance-rise → 1, 0 | duration-lg (320)ease-settle | Shows at once |
| TTR18 | Query is set (search) | query row | opacity, translate y | 0, distance-rise → 1, 0 | duration-lg (320)ease-settle | Shows at once |
| TTR19 | more is set | more line | opacity | 0 → 1 | duration-lg (320)ease-exit | Shows at once |
| TTR20 | Step status change (steps) | step marks | opacity, blur | 0, blur-swap → 1, 0 (the leaving mark runs back) | duration-md (260)ease-out | Swaps at once |
| TTR21 | Step status change (steps) | step marks | scale | scale-swap → 1 (the leaving mark runs back) | duration-xl (380)ease-out | Swaps at once |
| TTR22 | Step running to done (steps) | step check | stroke drawn | 0% → 100% | duration-xl (380)ease-outdelay 100ms | Shows at once |
| TTR23 | While a step runs | spinner (step mark or row spinner) | rotate, repeats | 0deg → 360deg | duration-6xl (900)ease-linear | No loop |
| TTR24 | Row running to done or error (search, tools) | row spinner slot | width | 12px → 0 | duration-xl (380)ease-out | Resizes at once |
| TTR25 | Row running to done or error (search, tools) | row spinner | opacity, blur, scale | 1, 0, 1 → 0, blur-swap, scale-swap | duration-md (260)ease-out | Hides at once |
| TTR26 | Hover | header | background color | transparent → bg-primary_hover | duration-2xs (120)ease-out | Swaps at once |
| TTR27 | Hover (link or button rows) | step row | background color | transparent → bg-primary_hover | duration-xs (160)ease-out | Swaps at once |
| TTR28 | Row detail opens or closes (tools) | row detail | height | 0 → content height | duration-2xl (420)ease-out | Resizes at once |
| TTR29 | Row detail opens or closes (tools) | row detail | opacity | 0 → 1 | duration-lg (320)ease-out | Shows at once |
| TTR30 | Row detail opens or closes (tools) | row chevron | rotation | -90deg → 0deg | duration-lg (320)ease-out | Swaps at once |
| TTR31 | Hover, keyboard focus or open, and back (tools rows with output) | step icon and row chevron | opacity, blur | 0, blur-swap → 1, 0 (the leaving one runs back) | duration-md (260)ease-out | Swaps at once |
| TTR32 | Hover, keyboard focus or open, and back (tools rows with output) | step icon and row chevron | scale | scale-swap → 1 (the leaving one runs back) | duration-xl (380)ease-out | Swaps at once |
| TTR33 | Hover (row with output) | argument chip | background color | bg-secondary → bg-tertiary | duration-2xs (120)ease-out | Swaps at once |
Accessibility
- The header is a button with aria-expanded and aria-controls pointing to the panel; its name is the label only, because the live region sits outside it.
- A visually hidden role="status" aria-live="polite" region says the running label and then the result (for example "Thought for 6s" or "Could not finish"); it never reads the ticking timer.
- The closed panel is inert, so its links and buttons are not in the tab order.
- Rows with a link open in a new tab and carry a visually hidden "(opens in a new tab)"; rows become buttons only when stepClick has a listener or the row has output.
- A tools row with output is a button with aria-expanded and aria-controls pointing to its detail block; the closed block is inert. The chevron shows on hover, on keyboard focus and while open, so keyboard users see the same cue as pointer users.
- The argument chip is plain text inside the row (not its own control), so each row is one Tab stop.
- Line counts have a visually hidden text such as "12 lines added, 3 lines removed"; the + and - signs carry the meaning, never color alone.
- Error rows say Failed in words next to the red mark; the header icon becomes an alert and the label says what happened.
- Marks, spinners, the chevron and the guide line are aria-hidden; rows are at least 28px tall.
- Under reduced motion the panel opens and folds at once, rows appear without rising, spinners stand still and the sweep is replaced by text-secondary.