Lunia DS

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.

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-trace

Usage

The demo above, as code.

demo.tsx
'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.

PropTypeDefaultDescription
stepsArray<{ 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.
labelstringper variantHeader while running. Defaults: "Thinking" (steps, reasoning), "Searching the web" (search), "Running tools" (tools).
doneLabelstringper variantHeader once settled. Defaults: "Thought for" with the time, "Finished thinking", "Searched the web" or "Ran 3 tools".
startedAtnumberundefinedStart time in ms. Shows a live timer and times the done label.
durationnumberundefinedRun time in ms, when the stream reports it.
querystringundefinedThe search query, shown above the sources.
morenumberundefinedNumber of rows not listed, shown as "+4 more".
openbooleanundefinedControlled open state of the panel.
defaultOpenbooleanundefinedOpen state at first when not controlled; without it the panel follows the status.
EventDescription
openChangeThe person opened or closed the panel. Payload: the new open state.
stepClickThe 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.
stepOpenChangeA tools row with output opened or closed its detail block. Payload: the step, its index and the new open state.
SlotDescription
iconReplaces the sparkle in the header.
stepIconScoped 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
TTR1While runningheader labelsweep of light (mask position), repeats150% → -50%duration-loop (1600)ease-linearNo loop
TTR2Status changeheader labeltext morphThinking → Thought for, Could not finish, Stoppedduration-3xl (460)ease-outSwaps at once
TTR3Running to settledheader labelcolortext-primary (with sweep) → text-secondaryduration-lg (320)ease-outSwaps at once
TTR4Running to settledheader iconcolorfg-secondary → fg-tertiaryduration-sm (200)ease-outSwaps at once
TTR5Status to error or canceled, or backheader iconsopacity, blur0, blur-swap → 1, 0 (the leaving icon runs back)duration-md (260)ease-outSwaps at once
TTR6Status to error or canceled, or backheader iconsscalescale-swap → 1 (the leaving icon runs back)duration-xl (380)ease-outSwaps at once
TTR7Timer, minutes column or Failed tag shows or hidesreveal (timer, minutes, Failed tag)column width (grid columns)0fr → 1frduration-2xl (420)ease-outResizes at once
TTR8Timer, minutes column or Failed tag showsreveal (timer, minutes, Failed tag)opacity, blur0, blur-swap → 1, 0duration-lg (320)ease-outShows at once
TTR9Timer, minutes column or Failed tag hidesreveal (timer, minutes, Failed tag)opacity1 → 0duration-xs (160)ease-outHides at once
TTR10Each whole second while runningtimer secondsnumber roll upn s → n + 1 sduration-4xl (520)ease-outSwaps at once
TTR11Open or closechevronrotation0deg → 180degduration-lg (320)ease-outSwaps at once
TTR12Open or closepanelheight0 → list heightduration-2xl (420)ease-outResizes at once
TTR13Open or closepanelopacity0 → 1duration-lg (320)ease-outShows at once
TTR14A row arrives or growspanelheightold list height → new list heightduration-2xl (420)ease-outResizes at once
TTR15Running to done (not error)panelfolds by itself (TTR12 and TTR13)open → closedduration-2xl (420)ease-outdelay duration-6xl (900)Resizes at once
TTR16First render with rowsstep rowopacity, translate y0, distance-rise → 1, 0duration-lg (320)ease-settledelay 90 x rowShows at once
TTR17A row arrives while streamingstep rowopacity, translate y0, distance-rise → 1, 0duration-lg (320)ease-settleShows at once
TTR18Query is set (search)query rowopacity, translate y0, distance-rise → 1, 0duration-lg (320)ease-settleShows at once
TTR19more is setmore lineopacity0 → 1duration-lg (320)ease-exitShows at once
TTR20Step status change (steps)step marksopacity, blur0, blur-swap → 1, 0 (the leaving mark runs back)duration-md (260)ease-outSwaps at once
TTR21Step status change (steps)step marksscalescale-swap → 1 (the leaving mark runs back)duration-xl (380)ease-outSwaps at once
TTR22Step running to done (steps)step checkstroke drawn0% → 100%duration-xl (380)ease-outdelay 100msShows at once
TTR23While a step runsspinner (step mark or row spinner)rotate, repeats0deg → 360degduration-6xl (900)ease-linearNo loop
TTR24Row running to done or error (search, tools)row spinner slotwidth12px → 0duration-xl (380)ease-outResizes at once
TTR25Row running to done or error (search, tools)row spinneropacity, blur, scale1, 0, 1 → 0, blur-swap, scale-swapduration-md (260)ease-outHides at once
TTR26Hoverheaderbackground colortransparent → bg-primary_hoverduration-2xs (120)ease-outSwaps at once
TTR27Hover (link or button rows)step rowbackground colortransparent → bg-primary_hoverduration-xs (160)ease-outSwaps at once
TTR28Row detail opens or closes (tools)row detailheight0 → content heightduration-2xl (420)ease-outResizes at once
TTR29Row detail opens or closes (tools)row detailopacity0 → 1duration-lg (320)ease-outShows at once
TTR30Row detail opens or closes (tools)row chevronrotation-90deg → 0degduration-lg (320)ease-outSwaps at once
TTR31Hover, keyboard focus or open, and back (tools rows with output)step icon and row chevronopacity, blur0, blur-swap → 1, 0 (the leaving one runs back)duration-md (260)ease-outSwaps at once
TTR32Hover, keyboard focus or open, and back (tools rows with output)step icon and row chevronscalescale-swap → 1 (the leaving one runs back)duration-xl (380)ease-outSwaps at once
TTR33Hover (row with output)argument chipbackground colorbg-secondary → bg-tertiaryduration-2xs (120)ease-outSwaps 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.