Agent statusReasoning trail
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callReasoning 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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add reasoning-trailReact
npx designme add reasoning-trail --reactThe 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.
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.
- It shows one of four views, picked with
view: plan, notes, sources or edits. A view with nothing in it falls back to the first one that has content. - Plan steps are done (a check and their
detail), now (a pulse and "Working") or next (a grey dot and "Next"). - Notes are plain lines, sources are file and link chips with tooltips, and edits list each file with its removed and added lines.
- While
runningis true, the header shimmers and counts seconds, unless you passseconds.titlereplaces the default header text. - Clicking the header, or pressing Enter or Space on it, folds the trail open or shut, controlled with
openandonOpenChangeor left to the component. Switching views grows or shrinks the open trail to its new height and plays the entrance again. - It sits flat in a chat thread by default, and
bare={false}gives it a card of its own.
- The header is a button with
aria-expanded, which follows every click, Enter and Space. - When folded, the body is
inertandaria-hidden, so its chips leave the tab order. - The thinking glyph and status dots are decorative and hidden from screen readers.
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>
);
}
Props
Everything ReasoningTrail takes.
| Prop | Type | What it does |
|---|---|---|
running | boolean | Shimmers and counts seconds while true |
seconds | number | Seconds to show. Leave it out to let the trail count while it runs |
title | string | Header text. Defaults to "Reasoning" while running and "Reasoned for Ns" after |
plan | PlanStep[] | |
notes | string[] | |
sources | Source[] | |
edits | Edit[] | |
view | ReasoningTrailView | 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 |
defaultView | ReasoningTrailView | |
onViewChange | (view: ReasoningTrailView) => void | |
open | boolean | Whether the trail is open. Leave it out to let the header toggle it |
defaultOpen | boolean | |
onOpenChange | (open: boolean) => void | |
bare | boolean | Sits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own |
className | string |
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);
});
});
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
planas steps withstateset to 'done', 'now' or 'next', and update them from your own state as the agent moves on. Setrunningto false when it finishes. - What does an edit look like?
- An edit has a
file, an optionaldetaillike "2 lines", andremovedandaddedarrays of lines. Removed lines are shown first, then added ones, under the file name.
More in Agent status
Show what the agent is doing, how far along it is, and what it is working with.