Agent statusRun summary
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callRun summary
What the agent did, what changed and what needs you. 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 run-summaryReact
npx designme add run-summary --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.
Run summary is the report an agent leaves when a run finishes: a one-line summary, a few headline numbers, the files it changed with lines added and removed, and anything that still needs the person. Buttons at the bottom open the changes or undo them all.
- At the end of an agent run, when people need to see at a glance what happened before they review the details.
- When the run changed files and people should see which ones and by how much.
- When the agent finished but left questions or checks that need a person.
- When people should be able to undo a whole run in one click.
statslike files, steps, tokens or cost count up from zero one after another, with an optionalprefix,suffixand number ofdecimals.- "What changed" lists each file in
changeswith its added and removed line counts. - "Needs you" lists each item in
needswith an optional action button. Settingbusyon the action shows a spinner, and the item's text crossfades when it changes. - "Open changes" calls
onOpenChanges, andonDetailsadds a menu button in the corner for the run's details. - "Undo all" switches the card to its undone look, changes the title to
undoneTitleand turns the button into Redo. Control it withundoneandonUndoneChange. - The rows come in one after another when the summary appears. It sits flat in a chat thread by default, and
bare={false}gives it a card.
- A "Needs you" action button sets
aria-busywhile it is busy. - The corner details button is labelled "More" and shows a "Run details" tooltip on hover or keyboard focus.
- Under
prefers-reduced-motion: reduce, the stats jump straight to their values instead of counting up.
Usage
Import it and drive it from your own state.
Import
import { RunSummary } from '@/components/designme/run-summary/RunSummary';Example
The demo above as a React component, 24 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 { RunSummary, type RunNeed } from './RunSummary';
import { useScript } from '../lib/useScript';
/* ---------- Run summary: the numbers roll in, Review settles the one thing left, Undo all takes it back ---------- */
export default function RunSummaryDemo() {
const [need, setNeed] = useState<'open' | 'busy' | 'fixed'>('open');
// Review spins for a second, then the question is answered
useScript(async ({ wait }) => { if (need === 'busy') { await wait(1000); setNeed('fixed'); } }, [need]);
const needs: RunNeed[] = [need === 'fixed'
? { key: 'badge', text: 'Badge moved above the price, nothing left to review' }
: { key: 'badge', text: 'The yearly badge overlaps the price on mobile', action: { label: 'Review', busy: need === 'busy', onClick: () => setNeed('busy') } }];
return (
<div style={{ width: '100%' }}>
<RunSummary subtitle="Finished in 12m 40s" summary="Pricing cards now use spacing tokens"
stats={[{ label: 'files', value: 6 }, { label: 'steps', value: 14 }, { label: 'tokens', value: 182, suffix: 'k' }, { label: 'cost', value: 0.84, prefix: '$', decimals: 2 }]}
changes={[{ file: 'PricingCard.tsx', added: 24, removed: 18 }, { file: 'tokens.css', added: 9, removed: 2 }, { file: 'Pricing.test.tsx', added: 31, removed: 0 }]}
needs={needs} onDetails={() => {}} onOpenChanges={() => {}} />
</div>
);
}
Props
Everything RunSummary takes.
| Prop | Type | What it does |
|---|---|---|
title | string | Defaults to "Run complete" |
undoneTitle | string | The title once the changes are undone |
subtitle | string | The quiet line under the title, like "Finished in 12m 40s" |
summary | ReactNode | One line on what the run did |
stats | RunStat[] | |
changes | RunChange[] | The files the run changed, under "What changed" |
needs | RunNeed[] | What is left for the person, under "Needs you" |
onDetails | () => void | Adds a button in the corner that opens the run's details |
onOpenChanges | () => void | The main button, "Open changes" |
undone | boolean | Whether the changes are undone. Leave it out to let "Undo all" toggle it |
defaultUndone | boolean | |
onUndoneChange | (undone: 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.
RunSummary.tsxReact · 138 lines
'use client';
import { useEffect, useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useTween } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { enter, Spin, Swap, Tip } from '../lib/motion';
import '../designme.css';
import './run-summary.css';
export interface RunStat {
/** Like "files" or "cost" */
label: string;
/** Counts up from 0 when the summary appears */
value: number;
/** Before the number, like "$" */
prefix?: string;
/** After the number, like "k" */
suffix?: string;
/** Decimal places, like 2 for a cost */
decimals?: number;
}
export interface RunChange {
file: string;
added: number;
removed: number;
}
export interface RunNeed {
key: string;
/** What needs the person. Crossfades when it changes */
text: string;
/** A button at the end, like Review. busy swaps its label for a spinner */
action?: { label: string; onClick: () => void; busy?: boolean };
}
export interface RunSummaryProps {
/** Defaults to "Run complete" */
title?: string;
/** The title once the changes are undone */
undoneTitle?: string;
/** The quiet line under the title, like "Finished in 12m 40s" */
subtitle?: string;
/** One line on what the run did */
summary?: ReactNode;
stats?: RunStat[];
/** The files the run changed, under "What changed" */
changes?: RunChange[];
/** What is left for the person, under "Needs you" */
needs?: RunNeed[];
/** Adds a button in the corner that opens the run's details */
onDetails?: () => void;
/** The main button, "Open changes" */
onOpenChanges?: () => void;
/** Whether the changes are undone. Leave it out to let "Undo all" toggle it */
undone?: boolean;
defaultUndone?: boolean;
onUndoneChange?: (undone: 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;
}
function Stat({ stat, delay }: { stat: RunStat; delay: number }) {
// each number starts rolling a beat after the one before it
const [go, setGo] = useState(false);
useEffect(() => { const t = setTimeout(() => setGo(true), delay); return () => clearTimeout(t); }, [delay]);
const v = useTween(go ? stat.value : 0, 1100, 0);
return <span><b className="dm-num">{go ? (stat.prefix ?? '') + v.toFixed(stat.decimals ?? 0) + (stat.suffix ?? '') : '0'}</b> {stat.label}</span>;
}
/** What an agent run did, what it cost and what still needs the person, once it finishes. */
export function RunSummary({ title = 'Run complete', undoneTitle = 'Changes undone', subtitle, summary, stats, changes, needs, onDetails, onOpenChanges, undone, defaultUndone = false, onUndoneChange, bare = true, className }: RunSummaryProps) {
const [isUndone, setUndone] = useControllable(undone, defaultUndone, onUndoneChange);
// the glyph only plays its entrance once it has changed, not when the summary first appears
const [seen, setSeen] = useState(isUndone);
const [moved, setMoved] = useState(false);
if (seen !== isUndone) { setSeen(isUndone); setMoved(true); }
// the rows come in one after another, across both sections
let row = 0;
const next = () => enter(300 + row++ * 70);
return (
<div data-dm="" className={cx('dm-rsu', !bare && 'dm-panel', isUndone && 'dm-undone', className)} style={bare ? { paddingInline: 0 } : undefined}>
<div className="dm-rsu-h">
<span className="dm-rsu-g" style={isUndone ? { background: 'var(--dm-chip)', color: 'var(--dm-fg2)' } : undefined}>
<Icon key={isUndone ? 'undo' : 'check'} name={isUndone ? 'undo' : 'check'} className={moved ? 'dm-enter' : undefined} />
</span>
<div>
<Swap className="dm-rsu-t">{isUndone ? undoneTitle : title}</Swap>
{subtitle && <div className="dm-rsu-s dm-muted">{subtitle}</div>}
</div>
{onDetails && <Tip label="Run details"><button type="button" className="dm-ib dm-rsu-more" aria-label="More" onClick={onDetails}><Icon name="more" /></button></Tip>}
</div>
{summary && <p className="dm-rsu-title">{summary}</p>}
{stats?.length ? <div className="dm-rsu-stats">{stats.map((s, i) => <Stat key={s.label} stat={s} delay={200 + i * 90} />)}</div> : null}
{changes?.length ? (
<div className="dm-rsu-sec">
<div className="dm-rsu-l">What changed</div>
{changes.map(c => {
const e = next();
return (
<div key={c.file} className={cx('dm-rsu-it', e.className)} style={e.style}>
<span className="dm-mono">{c.file}</span>
<span className="dm-num"><i className={c.added ? 'dm-a' : 'dm-z'}>+{c.added}</i> <i className={c.removed ? 'dm-x' : 'dm-z'}>−{c.removed}</i></span>
</div>
);
})}
</div>
) : null}
{needs?.length ? (
<div className="dm-rsu-sec">
<div className="dm-rsu-l">Needs you</div>
{needs.map(n => {
const e = next();
return (
<div key={n.key} className={cx('dm-rsu-it dm-rsu-need', e.className)} style={e.style}>
<span className="dm-dot" style={{ color: 'var(--dm-warn)' }} />
<Swap className="dm-rsu-nt">{n.text}</Swap>
{n.action && <button type="button" className="dm-pill dm-soft dm-sm dm-rsu-fix" onClick={n.action.busy ? undefined : n.action.onClick} aria-busy={n.action.busy || undefined}>{n.action.busy ? <Spin /> : n.action.label}</button>}
</div>
);
})}
</div>
) : null}
{(() => {
const e = next();
return (
<div className={cx('dm-rsu-acts', e.className)} style={e.style}>
<button type="button" className="dm-pill dm-primary dm-sm" onClick={onOpenChanges}>Open changes</button>
<button type="button" className="dm-pill dm-ghost dm-sm dm-rsu-undo" onClick={() => setUndone(!isUndone)}><Swap>{isUndone ? 'Redo' : 'Undo all'}</Swap></button>
</div>
);
})()}
</div>
);
}
run-summary.cssReact · 23 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Run summary ---------- */
.dm-rsu{padding:var(--dm-s-4)}
.dm-rsu-h{display:flex;align-items:center;gap:var(--dm-s-3)}
.dm-rsu-g{width:34px;height:34px;border-radius:50%;background:var(--dm-ok-bg);color:var(--dm-ok);display:grid;place-items:center;transition:background .4s,color .4s}
.dm-rsu-t{font-weight:500}
.dm-rsu-s{font-size:12.5px}
.dm-rsu-more{margin-left:auto}
.dm-rsu-title{margin:14px 0 0;font-size:14px;font-weight:500;line-height:1.45}
.dm-rsu-stats{display:flex;flex-wrap:wrap;gap:var(--dm-s-1) var(--dm-s-4);margin-top:6px;font-size:12.5px;color:var(--dm-muted)}
.dm-rsu-stats b{font-weight:500;color:var(--dm-fg2)}
.dm-rsu-sec{padding-top:var(--dm-s-3);margin-top:14px;border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-rsu-l{font-size:12px;color:var(--dm-muted);margin-bottom:6px}
.dm-rsu-it{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:var(--dm-s-1) 0;font-size:13px;transition:opacity .4s,filter .4s}
.dm-rsu-it .dm-mono{font-size:12.5px}
.dm-rsu-need{justify-content:flex-start}
.dm-rsu-need .dm-rsu-nt{flex:1;min-width:0}
.dm-rsu-it i{font-style:normal}.dm-rsu-it .dm-a{color:var(--dm-add)}.dm-rsu-it .dm-x{color:var(--dm-del)}.dm-rsu-it .dm-z{color:var(--dm-muted)}
.dm-rsu.dm-undone .dm-rsu-it:not(.dm-rsu-need){opacity:.45;filter:grayscale(1)}
.dm-rsu.dm-undone .dm-rsu-it .dm-mono{text-decoration:line-through}
.dm-rsu-acts{display:flex;gap:6px;margin-top:14px}
@media (max-width:420px){.dm-rsu-stats{grid-template-columns:repeat(2,1fr)}}
run-summary.htmlHTML · 24 lines
<div class="card bare" data-c="run-summary">
<div class="stage-in">
<div class="rsu panel">
<div class="rsu-h"><span class="rsu-g"><svg class="i s"><use href="#i-check"/></svg></span><div><div class="rsu-t">Run complete</div><div class="rsu-s muted">Finished in 12m 40s</div></div><button class="ib rsu-more" aria-label="More" data-tip="Run details"><svg class="i s"><use href="#i-more"/></svg></button></div>
<p class="rsu-title">Pricing cards now use spacing tokens</p>
<div class="rsu-stats">
<span><b class="num" data-n="6">0</b> files</span>
<span><b class="num" data-n="14">0</b> steps</span>
<span><b class="num" data-n="182" data-u="k">0</b> tokens</span>
<span><b class="num" data-n="0.84" data-p="$">0</b> cost</span>
</div>
<div class="rsu-sec"><div class="rsu-l">What changed</div>
<div class="rsu-it"><span class="mono">PricingCard.tsx</span><span class="num"><i class="a">+24</i> <i class="x">−18</i></span></div>
<div class="rsu-it"><span class="mono">tokens.css</span><span class="num"><i class="a">+9</i> <i class="x">−2</i></span></div>
<div class="rsu-it"><span class="mono">Pricing.test.tsx</span><span class="num"><i class="a">+31</i> <i class="z">−0</i></span></div>
</div>
<div class="rsu-sec"><div class="rsu-l">Needs you</div>
<div class="rsu-it rsu-need"><span class="dot" style="color:var(--warn)"></span><span class="rsu-nt">The yearly badge overlaps the price on mobile</span><button class="pill soft sm rsu-fix">Review</button></div>
</div>
<div class="rsu-acts"><button class="pill primary sm">Open changes</button><button class="pill ghost sm rsu-undo">Undo all</button></div>
</div>
</div>
</div>
run-summary.cssHTML · 22 lines
/* ---------- Run summary ---------- */
.rsu{padding:var(--s-4)}
.rsu-h{display:flex;align-items:center;gap:var(--s-3)}
.rsu-g{width:34px;height:34px;border-radius:50%;background:var(--ok-bg);color:var(--ok);display:grid;place-items:center;transition:background .4s,color .4s}
.rsu-t{font-weight:500}
.rsu-s{font-size:12.5px}
.rsu-more{margin-left:auto}
.rsu-title{margin:14px 0 0;font-size:14px;font-weight:500;line-height:1.45}
.rsu-stats{display:flex;flex-wrap:wrap;gap:var(--s-1) var(--s-4);margin-top:6px;font-size:12.5px;color:var(--muted)}
.rsu-stats b{font-weight:500;color:var(--fg2)}
.rsu-sec{padding-top:var(--s-3);margin-top:14px;border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.rsu-l{font-size:12px;color:var(--muted);margin-bottom:6px}
.rsu-it{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:var(--s-1) 0;font-size:13px;transition:opacity .4s,filter .4s}
.rsu-it .mono{font-size:12.5px}
.rsu-need{justify-content:flex-start}
.rsu-need .rsu-nt{flex:1;min-width:0}
.rsu-it i{font-style:normal}.rsu-it .a{color:var(--add)}.rsu-it .x{color:var(--del)}.rsu-it .z{color:var(--muted)}
.rsu.undone .rsu-it:not(.rsu-need){opacity:.45;filter:grayscale(1)}
.rsu.undone .rsu-it .mono{text-decoration:line-through}
.rsu-acts{display:flex;gap:6px;margin-top:14px}
@media (max-width:420px){.rsu-stats{grid-template-columns:repeat(2,1fr)}}
run-summary.jsHTML · 13 lines
DM.def('run-summary', (r, c) => {
r.innerHTML = r.innerHTML.replace('{ok}', I('check')).replace('{more}', I('more'));
const root = $('.rsu', r), undo = $('.rsu-undo', r), fix = $('.rsu-fix', r), g = $('.rsu-g', r);
$$('.rsu-stats b', r).forEach((b, i) => c.later(200 + i * 90, () => tween(0, parseFloat(b.dataset.n), 1100, v => { b.textContent = (b.dataset.p || '') + (b.dataset.n.includes('.') ? v.toFixed(2) : Math.round(v)) + (b.dataset.u || ''); })));
stagger($$('.rsu-it,.rsu-acts', r), 70, 300);
undo.onclick = () => {
const on = root.classList.toggle('undone');
swap(undo, on ? 'Redo' : 'Undo all'); swap($('.rsu-t', r), on ? 'Changes undone' : 'Run complete');
g.style.background = on ? 'var(--chip)' : ''; g.style.color = on ? 'var(--fg2)' : ''; g.innerHTML = I(on ? 'undo' : 'check'); enter(g.firstElementChild);
};
fix.onclick = () => { fix.innerHTML = '<span class="spin"></span>'; c.later(1000, () => { fix.remove(); swap($('.rsu-nt', r), 'Badge moved above the price, nothing left to review'); }); };
});
Questions
What people ask about it.
- Does Undo all revert the files?
- No. It changes the card's state and calls
onUndoneChange(true). Your code reverts the changes, and Redo calls it again with false. - How do I show that a "Needs you" item is being handled?
- Set
busy: trueon itsactionwhile you work on it, then replace itstext, and drop the action, once it is resolved. The new text crossfades in. - Can I show a cost with two decimals?
- Yes. Pass a stat like
{ label: 'cost', value: 0.84, prefix: '$', decimals: 2 }.
More in Agent status
Show what the agent is doing, how far along it is, and what it is working with.