Skip to content
Star on GitHub
Agent statusReasoning trail
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Reasoning trail

The agent's plan, notes, sources and edits, after it thinks. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add reasoning-trail
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add reasoning-trail

React

$npx designme add reasoning-trail --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Reasoning trail is a foldable record of how an agent worked through a task: its plan, its notes, the sources it used and the edits it made. It sits in a chat thread under a header that reads "Reasoning" with a running clock, then "Reasoned for Ns" once the agent is done.

  • When people want to check how the agent reached its answer before they trust it.
  • When the agent follows a plan and you want each step to show as done, in progress or next.
  • When the agent changed files and people should see the removed and added lines in place.
  • When the reasoning should stay out of the way until someone opens it.
03

Usage

Import it and drive it from your own state.

Import

import { ReasoningTrail } from '@/components/designme/reasoning-trail/ReasoningTrail';

Example

The demo above as a React component, 40 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useState } from 'react';
import { ReasoningTrail, type PlanStep, type ReasoningTrailProps, type ReasoningTrailView } from './ReasoningTrail';
import { Seg } from '../lib/motion';
import { Demo } from '../lib/Demo';
import { useScript } from '../lib/useScript';

/* ---------- Reasoning trail: the plan fills in, then the trail settles ---------- */
const VIEWS: ReasoningTrailView[] = ['plan', 'notes', 'sources', 'edits'];
const PLAN = [['Open the onboarding file', '4 frames'], ['Compare it with the design system', '12 components'], ['Find spacing that breaks the 8px grid', '3 frames'], ['Draft fixes for review', 'Ready']];
const CONTENT: Pick<ReasoningTrailProps, 'notes' | 'sources' | 'edits'> = {
  notes: ['The hero card uses 20px padding, every other card uses 24px.', 'Mobile frames already follow the grid, desktop drifted.', 'Two buttons use a hard-coded grey instead of a token.'],
  sources: [{ label: 'Onboarding.fig', kind: 'figma', tip: 'Figma, 4 frames read' }, { label: 'tokens.ts', kind: 'ts', tip: 'src/styles/tokens.ts, 212 lines' }, { label: 'Card.tsx', kind: 'tsx', tip: 'src/components/Card.tsx, 2 edits' }, { label: 'Spacing guide', kind: 'web', tip: 'designme.dev/guide/spacing' }],
  edits: [{ file: 'Card.tsx', detail: '2 lines', removed: ['padding: 20px;'], added: ['padding: var(--space-6);'] }, { file: 'Button.tsx', detail: '1 line', removed: ['color: #8a8a8a;'], added: ['color: var(--text-dim);'] }]
};
export default function ReasoningTrailDemo() {
  const [done, setDone] = useState(0);
  const [view, setView] = useState<ReasoningTrailView>('plan');
  useScript(async ({ wait }) => { for (let i = 1; i <= PLAN.length; i++) { await wait(1500); setDone(i); } });
  const running = done < PLAN.length;
  const plan: PlanStep[] = PLAN.map(([label, detail], i) => ({ label, detail, state: i < done ? 'done' : i === done ? 'now' : 'next' }));
  return (
    <Demo switcher={<Seg label="View" value={view} onChange={setView} options={[{ value: 'plan', label: 'Plan' }, { value: 'notes', label: 'Notes' }, { value: 'sources', label: 'Sources' }, { value: 'edits', label: 'Edits' }]} />}>
      {/* the preview keeps the room of its tallest view, so the switcher under it stays put: a hidden copy of each view
          sits in the same grid cell, and the trail you see is centred in it */}
      <div style={{ display: 'grid', width: '100%' }}>
        {VIEWS.map(v => (
          <div key={v} aria-hidden="true" inert style={{ gridArea: '1 / 1', visibility: 'hidden', pointerEvents: 'none' }}>
            <ReasoningTrail plan={plan} view={v} seconds={0} {...CONTENT} />
          </div>
        ))}
        <div style={{ gridArea: '1 / 1', alignSelf: 'center', minWidth: 0 }}>
          <ReasoningTrail running={running} plan={plan} view={view} {...CONTENT} />
        </div>
      </div>
    </Demo>
  );
}
04

Props

Everything ReasoningTrail takes.

PropTypeWhat it does
runningbooleanShimmers and counts seconds while true
secondsnumberSeconds to show. Leave it out to let the trail count while it runs
titlestringHeader text. Defaults to "Reasoning" while running and "Reasoned for Ns" after
planPlanStep[]
notesstring[]
sourcesSource[]
editsEdit[]
viewReasoningTrailViewWhich part of the trail it shows: the plan, the notes, the sources or the edits. Leave it out to start on the first one there is
defaultViewReasoningTrailView
onViewChange(view: ReasoningTrailView) => void
openbooleanWhether the trail is open. Leave it out to let the header toggle it
defaultOpenboolean
onOpenChange(open: boolean) => void
barebooleanSits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own
classNamestring
05

Code

The full source, as the CLI installs it.

ReasoningTrail.tsxReact · 116 lines
'use client';
import { useRef, type KeyboardEvent } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useIsoLayoutEffect, useReducedMotion, useSeconds } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Dot, enter, Fold, Pulse, SourceChip, Swap, Think, type Source } from '../lib/motion';
import '../designme.css';
import './reasoning-trail.css';

export interface PlanStep {
  label: string;
  /** Shown under a finished step, like "4 frames" */
  detail?: string;
  state: 'done' | 'now' | 'next';
}
export interface Edit {
  file: string;
  /** Like "2 lines" */
  detail?: string;
  removed?: string[];
  added?: string[];
}
export type ReasoningTrailView = 'plan' | 'notes' | 'sources' | 'edits';
/** The earlier name of ReasoningTrailView */
export type ReasoningTab = ReasoningTrailView;

export interface ReasoningTrailProps {
  /** Shimmers and counts seconds while true */
  running?: boolean;
  /** Seconds to show. Leave it out to let the trail count while it runs */
  seconds?: number;
  /** Header text. Defaults to "Reasoning" while running and "Reasoned for Ns" after */
  title?: string;
  plan?: PlanStep[];
  notes?: string[];
  sources?: Source[];
  edits?: Edit[];
  /** Which part of the trail it shows: the plan, the notes, the sources or the edits. Leave it out to start on the first one there is */
  view?: ReasoningTrailView;
  defaultView?: ReasoningTrailView;
  onViewChange?: (view: ReasoningTrailView) => void;
  /** Whether the trail is open. Leave it out to let the header toggle it */
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** Sits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own */
  bare?: boolean;
  className?: string;
}

const VIEWS: ReasoningTrailView[] = ['plan', 'notes', 'sources', 'edits'];

/** The agent's plan, notes, sources or edits, under a header it can fold away. Pick what it shows with `view`. */
export function ReasoningTrail({ running = false, seconds, title, plan, notes, sources, edits, view, defaultView, onViewChange, open, defaultOpen = true, onOpenChange, bare = true, className }: ReasoningTrailProps) {
  const counted = useSeconds(running && seconds === undefined);
  const secs = seconds ?? counted;
  const has = { plan: !!plan?.length, notes: !!notes?.length, sources: !!sources?.length, edits: !!edits?.length };
  const [picked] = useControllable(view, defaultView ?? VIEWS.find(v => has[v]) ?? 'plan', onViewChange);
  // a view with nothing in it falls back to the first one that has something
  const tab = has[picked] ? picked : VIEWS.find(v => has[v]) ?? picked;
  const [isOpen, setOpen] = useControllable(open, defaultOpen, onOpenChange);
  const head = title ?? (running ? 'Reasoning' : `Reasoned for ${Math.max(secs, 1)}s`);
  // Enter and Space open and fold it as a click does, and aria-expanded always says which
  const onHeadKey = (e: KeyboardEvent<HTMLButtonElement>) => {
    if ((e.key !== 'Enter' && e.key !== ' ') || e.altKey || e.ctrlKey || e.metaKey) return;
    e.preventDefault(); if (!e.repeat) setOpen(!isOpen);
  };

  // switching views grows or shrinks the open trail to its new height instead of jumping to it
  const reduce = useReducedMotion();
  const bodyRef = useRef<HTMLDivElement>(null), seen = useRef({ tab, h: 0 }), anim = useRef<Animation | null>(null);
  useIsoLayoutEffect(() => {
    const el = bodyRef.current; if (!el) return;
    if (anim.current?.playState === 'running' && seen.current.tab === tab) return;
    const to = el.offsetHeight, from = seen.current.h, moved = seen.current.tab !== tab;
    seen.current = { tab, h: to };
    if (!moved || !isOpen || reduce || !from || Math.abs(from - to) < 1 || !el.animate) return;
    const cs = getComputedStyle(el), ms = parseFloat(cs.getPropertyValue('--dm-t')) * 1000 || 450;
    anim.current?.cancel(); el.style.overflow = 'hidden';
    const a = el.animate([{ height: from + 'px' }, { height: to + 'px' }], { duration: ms, easing: cs.getPropertyValue('--dm-ease').trim() || 'ease' });
    a.onfinish = a.oncancel = () => { el.style.overflow = ''; if (anim.current === a) anim.current = null; };
    anim.current = a;
  });

  const body = () => {
    if (tab === 'plan') return plan?.map((p, i) => (
      <div key={'p' + i} className={cx('dm-rtr-li dm-enter', p.state === 'now' && 'dm-now')} style={enter(i * 50).style}>
        <span className="dm-g">{p.state === 'done' ? <span style={{ color: 'var(--dm-ok)', display: 'grid' }}><Icon name="check" size="xs" /></span> : p.state === 'now' ? <Pulse /> : <Dot color="var(--dm-faint)" />}</span>
        <span>{p.label}<span className="dm-d">{p.state === 'done' ? p.detail ?? 'Done' : p.state === 'now' ? 'Working' : 'Next'}</span></span>
      </div>));
    if (tab === 'notes') return notes?.map((t, i) => (
      <div key={'n' + i} className="dm-rtr-li dm-enter" style={enter(i * 50).style}><span className="dm-g"><Dot color="var(--dm-faint)" /></span><span>{t}</span></div>));
    if (tab === 'sources') return <div className="dm-rtr-src dm-enter">{/* the tips open below the chips, clear of the header above */}{sources?.map((s, i) => <SourceChip key={i} source={s} tipSide="bottom" />)}</div>;
    return edits?.map((e, i) => (
      <div key={'e' + i} className="dm-enter" style={enter(i * 50).style}>
        <div className="dm-rtr-li"><span className="dm-g"><Icon name="edit" size="xs" /></span><span>{e.file}{e.detail && <span className="dm-d">{e.detail}</span>}</span></div>
        <div className="dm-rtr-edit">{e.removed?.map((l, k) => <div key={'x' + k} className="dm-x">{l}</div>)}{e.added?.map((l, k) => <div key={'a' + k} className="dm-a">{l}</div>)}</div>
      </div>));
  };

  return (
    <div data-dm="" className={cx('dm-rtr', !bare && 'dm-panel', className)} style={bare ? { paddingInline: 0 } : undefined}>
      <button type="button" className="dm-rtr-h" aria-expanded={isOpen} onClick={() => setOpen(!isOpen)} onKeyDown={onHeadKey}>
        <Think on={running} className="dm-rtr-mark" />
        <Swap className={cx('dm-rtr-title', running && 'dm-shim')}>{head}</Swap>
        <span className="dm-rtr-time dm-num dm-muted">{running ? secs + 's' : ''}</span>
        <Icon name="chev-d" size="xs" className="dm-rtr-chev" />
      </button>
      <Fold open={isOpen} className="dm-rtr-fold">
        {/* keyed by view, so switching views plays the entrance again */}
        <div className="dm-rtr-body" key={tab} ref={bodyRef}>{body()}</div>
      </Fold>
    </div>
  );
}
reasoning-trail.cssReact · 18 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Reasoning trail ---------- */
.dm-rtr{width:100%;padding:6px}
.dm-rtr-h{display:flex;align-items:center;gap:10px;width:100%;padding:10px var(--dm-s-3);border-radius:var(--dm-r-in);font-size:13.5px;transition:background .2s}
@media (hover:hover) and (pointer:fine){.dm-rtr-h:hover{background:var(--dm-row)}}
.dm-rtr-mark{color:var(--dm-fg2)}
.dm-rtr-chev{margin-left:auto;color:var(--dm-muted);transition:transform .4s var(--dm-ease)}
.dm-rtr-h[aria-expanded="false"] .dm-rtr-chev{transform:rotate(-90deg)}
.dm-rtr-body{padding:var(--dm-s-1) var(--dm-s-3) 10px}
.dm-rtr-li{position:relative;display:flex;gap:10px;padding:6px 0 6px var(--dm-s-6);font-size:13.5px;color:var(--dm-fg2)}
.dm-rtr-li .dm-g{position:absolute;left:0;top:7px;width:16px;height:16px;display:grid;place-items:center;color:var(--dm-muted)}
.dm-rtr-li:not(:last-child)::before{content:"";position:absolute;left:7.5px;top:25px;bottom:-6px;width:1px;background:var(--dm-rail)}
.dm-rtr-li .dm-d{display:block;font-size:12px;color:var(--dm-muted);margin-top:1px}
.dm-rtr-li.dm-now{color:var(--dm-fg)}
.dm-rtr-edit{font-family:var(--dm-mono);font-size:12px;border-radius:10px;background:var(--dm-chip);padding:var(--dm-s-2) 10px;margin:var(--dm-s-1) 0 var(--dm-s-2);line-height:1.65}
.dm-rtr-edit .dm-a{color:var(--dm-add)}.dm-rtr-edit .dm-x{color:var(--dm-del);text-decoration:line-through;text-decoration-color:color-mix(in srgb,var(--dm-del) 40%,transparent)}
.dm-rtr-src{display:flex;flex-wrap:wrap;gap:6px}
reasoning-trail.htmlHTML · 17 lines
<div class="card bare" data-c="reasoning-trail">
  <div class="stage-in">
    <div class="rtr-dm dm"><div class="dm-main"><div class="rtr panel">
      <button class="rtr-h" aria-expanded="true">
        <span class="think on rtr-mark"><i></i><i></i><i></i><i></i></span>
        <span class="rtr-title shim">Reasoning</span><span class="rtr-time num muted">0s</span>
        <svg class="i xs rtr-chev"><use href="#i-chev-d"/></svg>
      </button>
      <div class="fold rtr-fold"><div>
        <div class="rtr-body"></div>
      </div></div>
    </div></div>
      <div class="demo"><div class="seg2" role="group" aria-label="View"><button aria-pressed="true" data-v="plan">Plan</button><button aria-pressed="false" data-v="notes">Notes</button><button aria-pressed="false" data-v="sources">Sources</button><button aria-pressed="false" data-v="edits">Edits</button></div></div>
    </div>
  </div>
</div>
reasoning-trail.cssHTML · 17 lines
/* ---------- Reasoning trail ---------- */
.rtr{width:100%;padding:6px}
.rtr-h{display:flex;align-items:center;gap:10px;width:100%;padding:10px var(--s-3);border-radius:var(--r-in);font-size:13.5px;transition:background .2s}
@media (hover:hover) and (pointer:fine){.rtr-h:hover{background:var(--row)}}
.rtr-mark{color:var(--fg2)}
.rtr-chev{margin-left:auto;color:var(--muted);transition:transform .4s var(--ease)}
.rtr-h[aria-expanded="false"] .rtr-chev{transform:rotate(-90deg)}
.rtr-body{padding:var(--s-1) var(--s-3) 10px}
.rtr-li{position:relative;display:flex;gap:10px;padding:6px 0 6px var(--s-6);font-size:13.5px;color:var(--fg2)}
.rtr-li .g{position:absolute;left:0;top:7px;width:16px;height:16px;display:grid;place-items:center;color:var(--muted)}
.rtr-li:not(:last-child)::before{content:"";position:absolute;left:7.5px;top:25px;bottom:-6px;width:1px;background:var(--rail)}
.rtr-li .d{display:block;font-size:12px;color:var(--muted);margin-top:1px}
.rtr-li.now{color:var(--fg)}
.rtr-edit{font-family:var(--mono);font-size:12px;border-radius:10px;background:var(--chip);padding:var(--s-2) 10px;margin:var(--s-1) 0 var(--s-2);line-height:1.65}
.rtr-edit .a{color:var(--add)}.rtr-edit .x{color:var(--del);text-decoration:line-through;text-decoration-color:color-mix(in srgb,var(--del) 40%,transparent)}
.rtr-src{display:flex;flex-wrap:wrap;gap:6px}
reasoning-trail.jsHTML · 50 lines
DM.def('reasoning-trail', (r, c) => {
  const body = $('.rtr-body', r), head = $('.rtr-h', r), fold = $('.rtr-fold', r), title = $('.rtr-title', r), time = $('.rtr-time', r), mark = $('.rtr-mark', r);
  let tab = 'plan', planDone = 0, secs = 0, finished = false;
  const plan = [['Open the onboarding file','4 frames'],['Compare it with the design system','12 components'],['Find spacing that breaks the 8px grid','3 frames'],['Draft fixes for review','Ready']];
  const views = {
    plan: () => plan.map((p, i) => `<div class="rtr-li ${i === planDone && !finished ? 'now' : ''}"><span class="g">${i < planDone ? `<span style="color:var(--ok)">${I('check','xs')}</span>` : i === planDone && !finished ? '<span class="pulse"></span>' : '<span class="dot" style="color:var(--faint)"></span>'}</span><span>${p[0]}<span class="d">${i < planDone ? p[1] : i === planDone && !finished ? 'Working' : 'Next'}</span></span></div>`).join(''),
    notes: () => ['The hero card uses 20px padding, every other card uses 24px.','Mobile frames already follow the grid, desktop drifted.','Two buttons use a hard-coded grey instead of a token.'].map(t => `<div class="rtr-li"><span class="g"><span class="dot" style="color:var(--faint)"></span></span><span>${t}</span></div>`).join(''),
    sources: () => `<div class="rtr-src"><button class="fchip lnk" data-tip-at="below" data-tip="Figma, 4 frames read">${I('figma','xs')}Onboarding.fig</button><button class="fchip lnk" data-tip-at="below" data-tip="src/styles/tokens.ts, 212 lines"><span class="ft">TS</span>tokens.ts</button><button class="fchip lnk" data-tip-at="below" data-tip="src/components/Card.tsx, 2 edits"><span class="ft re">TSX</span>Card.tsx</button><button class="fchip lnk" data-tip-at="below" data-tip="designme.dev/guide/spacing">${I('globe','xs')}Spacing guide</button></div>`,
    edits: () => `<div class="rtr-li"><span class="g">${I('edit','xs')}</span><span>Card.tsx<span class="d">2 lines</span></span></div><div class="rtr-edit"><div class="x">padding: 20px;</div><div class="a">padding: var(--space-6);</div></div><div class="rtr-li"><span class="g">${I('edit','xs')}</span><span>Button.tsx<span class="d">1 line</span></span></div><div class="rtr-edit"><div class="x">color: #8a8a8a;</div><div class="a">color: var(--text-dim);</div></div>`
  };
  const paint = anim => { body.innerHTML = views[tab](); if (anim) stagger(Array.from(body.children), 50); };
  // the header opens and folds the trail, by click, Enter or Space, and aria-expanded always says which
  let open = true;
  const setOpen = o => { open = o; fold.classList.toggle('shut', !o); fold.inert = !o; head.setAttribute('aria-expanded', o); };
  c.on(head, 'click', () => setOpen(!open));
  c.on(head, 'keydown', e => { if ((e.key === 'Enter' || e.key === ' ') && !e.altKey && !e.ctrlKey && !e.metaKey){ e.preventDefault(); if (!e.repeat) setOpen(!open); } });
  // the trail keeps the room of its tallest view, so the tabs under it stay put and the trail sits centred above them.
  // A hidden copy measures each view at the trail's own width
  const rtr = $('.rtr', r), main = rtr.closest('.dm-main'); let fitW = 0;
  const fit = () => {
    const w = rtr.offsetWidth; if (!main || !w || w === fitW) return; fitW = w;
    const ghost = rtr.cloneNode(true), gb = $('.rtr-body', ghost);
    ghost.inert = true; ghost.setAttribute('aria-hidden', 'true'); $('.rtr-fold', ghost).classList.remove('shut');
    Object.assign(ghost.style, { position:'absolute', left:'0', top:'0', width:w + 'px', visibility:'hidden', pointerEvents:'none' });
    main.appendChild(ghost);
    let h = 0; for (const v in views){ gb.innerHTML = views[v](); h = Math.max(h, ghost.offsetHeight); }
    ghost.remove(); main.style.minHeight = Math.ceil(h) + 'px'; main.style.justifyContent = 'center';
  };
  // switching views grows or shrinks the open trail to its new height instead of jumping to it
  const grow = from => {
    if (REDUCE || !open) return; const to = body.offsetHeight; if (Math.abs(to - from) < 1) return;
    const cs = getComputedStyle(body), ms = parseFloat(cs.getPropertyValue('--t')) * 1000 || 450;
    if (body._a) body._a.cancel();
    body.style.overflow = 'hidden';
    body._a = body.animate([{ height:from + 'px' }, { height:to + 'px' }], { duration:ms, easing:cs.getPropertyValue('--ease').trim() || 'ease' });
    body._a.onfinish = body._a.oncancel = () => { body.style.overflow = ''; body._a = null; };
  };
  seg($('.seg2', r), v => { const from = body.offsetHeight; tab = v; paint(true); grow(from); });
  paint(true); fit();
  const ro = new ResizeObserver(fit); ro.observe(main || rtr); c.offs.push(() => ro.disconnect());
  if (document.fonts) document.fonts.ready.then(() => { if (!c.dead){ fitW = 0; fit(); } });
  c.every(1000, () => { if (!finished){ secs++; time.textContent = secs + 's'; } });
  c.run(async () => {
    for (planDone = 0; planDone < plan.length; ){
      await c.wait(1500); planDone++; if (tab === 'plan') paint(false);
    }
    finished = true; title.classList.remove('shim'); mark.classList.remove('on'); swap(title, 'Reasoned for ' + Math.max(secs, 5) + 's'); time.textContent = ''; if (tab === 'plan') paint(false);
  });
});
06

Questions

What people ask about it.

Does the trail come with Plan, Notes, Sources and Edits tabs?
No. The component shows whichever view you pass in view, and you supply the switcher. The designme.dev demo puts a segmented control under it and keeps the room of the tallest view, so the control stays put as you switch.
How do I show progress through the plan?
Pass plan as steps with state set to 'done', 'now' or 'next', and update them from your own state as the agent moves on. Set running to false when it finishes.
What does an edit look like?
An edit has a file, an optional detail like "2 lines", and removed and added arrays of lines. Removed lines are shown first, then added ones, under the file name.
07

More in Agent status

Show what the agent is doing, how far along it is, and what it is working with.

ThinkingThe live trace, written as the agent works, then folded away.
Agent lanesSeveral agents working in parallel, each with its own lane and hand-offs.
Agent treeAn orchestrator and its sub-agents, with cost rolled up per branch.
Agent inboxEvery run that needs you, is running or is done, in one list.
Trace viewOne agent run as a waterfall of spans, with latency and tokens.
Browser sessionThe agent browsing for you, step by step, with Pause and Take over.
Run summaryWhat the agent did, what changed and what needs you.
Agent run timelineEvery model and tool step of a run on one time axis.
All 18 in Agent status →