Progress keeps the person informed while the agent works: what it is doing now, what is done, what is left, and what it has cost. A glance answers comfort; a closer look answers doubt. Trouble shows up as it happens, not at the end.
When to Use It
An agent runs long enough that "what is it doing?" needs an answer
Different readers need different depths — glance for ambient, inspect for trust
Failures must surface as they happen, not at the end
When Not To
The run is atomic and instant — show a result, not a trace
You'd be narrating activity that maps to nothing real
The trace would expose sensitive tool payloads to the wrong audience
Three views
The exchange between the human, the agent and the system the agent acts on; who holds each part of it; and the component that implements it.
01 · the interaction
02 · who holds what
01Status lineOne line on what the AI is doing right now
02Step logThe steps done and the steps still to come
03Tool callsEach action the AI took, with what went in and what came out
04Cost meterTime, money, or tokens used so far
05Glance vs. inspectTwo depths: a quick look for comfort, a full log for doubt
03 · the component · code: beta
vendor presets are recreations, not vendor assets
THE LIVE PLAYGROUND NEEDS A WIDER SCREEN — IT'S WAITING ON DESKTOP.
THE ANATOMY ABOVE AND THE CODE BELOW ARE THE SAME COMPONENT.
Do / Don't
Do
Ship two depths: a glanceable status line and an inspectable step log.
Don't
Force a single depth — terminal firehose or opaque spinner — on every reader.
Terminals show everything and chat surfaces show almost nothing; the pattern is the zoom, not either extreme.
Do
Bind the trace to real events — actual tool calls, actual steps.
Don't
Animate activity that maps to nothing the agent is doing.
Activity display can perform progress that is not happening; theater erodes exactly the trust the trace exists to build.
Do
Make the one step that matters findable — failures and blocks first.
Don't
Bury a failed step in a hundred successful ones.
Full traces without hierarchy hide the signal in the transparency.
Every pair cites a documented tension or principle boundary — no free-floating taste.
Get the Code
Namespaced install (add the registry to components.json once):
/**
* ProgressTrace — headless core.
* Pattern: streaming what the agent is doing right now, at glanceable and
* inspectable depth.
* Anatomy: Status line · Step log · Tool calls · Cost meter · Glance-vs-inspect
* · Health (stalled, disconnected) · Replay
*
* Config captures the vendor divergence: Claude Code exposes raw tool calls
* and cost accounting; ChatGPT agent mode narrates with screenshots and no
* cost meter (`showToolIO: false`, `showCost: false`).
*
* Two ways to drive it (spec 2026-10-04 §2):
* - Events: pass the run's `execution.*` contract events as `events`. The trace
* orders them by timestamp, drops duplicates and folds them into steps with
* attempts, errors, lanes and times. It emits nothing: the host owns the log.
* - Snapshot: pass `state`, `steps` and `statusText`. The trace derives the
* contract events from each change and hands them to `onEvent`.
* `replay` steps through a recorded event log one event at a time.
* Tool inputs and outputs pass through `redact` before anything shows them.
*/
import * as React from 'react';
import {
createEvent,
type EventDraft,
type ExecutionRunPayload,
type ExecutionStepPayload,
type InteractionEvent,
type InteractionEventHandler,
} from '@/lib/interaction-contract';
export type TraceState = 'idle' | 'running' | 'paused' | 'done' | 'error' | 'stopped';
export type Zoom = 'glance' | 'inspect';
export type StepStatus = 'pending' | 'active' | 'done' | 'failed';
/** The host's link to the agent. The trace cannot see it; the host says. */
export type Connection = 'connected' | 'reconnecting' | 'disconnected';
export interface TraceStep {
id: string;
label: string;
status: StepStatus;
toolCalls?: ToolCall[];
/** The plan step this trace step runs (PlanStep.id). Carried as the correlationId of its events. */
planStepId?: string;
/** Which attempt this is, from 1. */
attempt?: number;
/** How many attempts the host allows. */
maxAttempts?: number;
/** What went wrong on the last failed attempt. */
error?: string;
/** Parallel lane, from 0. Steps that run at once sit in different lanes. */
lane?: number;
/** ISO time the step first went active. */
startedAt?: string;
/** ISO time the step finished or failed. */
endedAt?: string;
}
export interface ToolCall {
id: string;
name: string;
/** Raw input. A string shows as it is; anything else shows as JSON. Redacted before display. */
input?: unknown;
/** Raw output, shown the same way. Redacted before display. */
output?: unknown;
}
export interface RedactContext {
step: TraceStep;
field: 'input' | 'output';
}
export type Redact = (value: unknown, ctx: RedactContext) => unknown;
export interface ProgressTraceConfig {
/** Expose raw tool inputs/outputs (inspect depth) vs. narration only. */
showToolIO: boolean;
/** Cost/token accounting part present? */
showCost: boolean;
defaultZoom: Zoom;
/** A running trace with no event for this long reads as stalled. null turns the check off. */
stallAfterMs: number | null;
/** Replay: time between events while playing. */
replayIntervalMs: number;
}
export const defaultConfig: ProgressTraceConfig = {
showToolIO: true,
showCost: true,
defaultZoom: 'glance',
stallAfterMs: 30_000,
replayIntervalMs: 800,
};
/* ---------------- redaction ---------------- */
/** Object keys that look like secrets. The default redaction masks their values. */
export const SECRET_KEY = /token|key|password|passwd|secret|authorization|cookie|credential/i;
export const REDACTED = '[redacted]';
const BEARER = /\bBearer\s+[A-Za-z0-9._~+/=-]+/g;
const SECRET_PAIR = /([\w-]*(?:token|key|password|passwd|secret|authorization|cookie|credential)[\w-]*)(\s*[:=]\s*)(?!Bearer\b)("[^"]*"|'[^']*'|[^\s,;&]+)/gi;
function maskDeep(value: unknown, seen: WeakSet<object>): unknown {
if (Array.isArray(value)) {
if (seen.has(value)) return REDACTED;
seen.add(value);
return value.map((v) => maskDeep(v, seen));
}
if (value && typeof value === 'object') {
if (seen.has(value)) return REDACTED;
seen.add(value);
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) out[k] = SECRET_KEY.test(k) ? REDACTED : maskDeep(v, seen);
return out;
}
if (typeof value === 'string') return maskText(value);
return value;
}
function maskText(text: string): string {
const t = text.trim();
if (t.startsWith('{') || t.startsWith('[')) {
try {
return JSON.stringify(maskDeep(JSON.parse(t), new WeakSet()));
} catch {
/* not JSON: mask as text */
}
}
return text.replace(BEARER, `Bearer ${REDACTED}`).replace(SECRET_PAIR, `$1$2${REDACTED}`);
}
/**
* The default `redact`: masks the value of any key that looks like a secret
* (`token`, `key`, `password`, `authorization` and kin), at any depth, inside
* objects, arrays and JSON strings; in plain text it masks `name=value`,
* `name: value` and `Bearer …`. It returns a copy and never changes the input.
*/
export function defaultRedact(value: unknown): unknown {
return maskDeep(value, new WeakSet());
}
/** A redacted value as display text. A redaction that throws hides the whole value. */
export function toolValueText(value: unknown, ctx: RedactContext, redact: Redact = defaultRedact): string | null {
if (value === undefined || value === null || value === '') return null;
let shown: unknown;
try {
shown = redact(value, ctx);
} catch {
return REDACTED;
}
if (shown === undefined || shown === null) return null;
if (typeof shown === 'string') return shown;
try {
return JSON.stringify(shown, null, 2);
} catch {
return REDACTED;
}
}
/* ---------------- events → trace ---------------- */
/** What a fold of the event log gives: the run and each step in first-seen order. */
export interface TraceView {
state: TraceState;
statusText: string;
steps: TraceStep[];
/** ISO time of the newest event folded in. */
lastEventAt: string | null;
/** Run-level attempt, from 1; a retry after the run failed adds one. */
runAttempt: number;
/** The run's error, after execution.failed. */
error?: string;
/** Who paused the run, while it is paused. */
pausedBy?: InteractionEvent['actor'];
}
export const emptyView: TraceView = { state: 'idle', statusText: '', steps: [], lastEventAt: null, runAttempt: 1 };
const time = (at: string) => {
const t = Date.parse(at);
return Number.isNaN(t) ? null : t;
};
/**
* The log in the order things happened: by timestamp, ties kept in arrival
* order, a repeated event id kept once. Events that arrive late, say after a
* reconnection, land where they belong.
*/
export function orderEvents(events: ReadonlyArray<InteractionEvent>, runId?: string): InteractionEvent[] {
const seen = new Set<string>();
const kept: Array<{ e: InteractionEvent; i: number; t: number | null }> = [];
events.forEach((e, i) => {
if (!e.type.startsWith('execution.')) return;
if (runId !== undefined && e.runId !== runId) return;
if (seen.has(e.id)) return;
seen.add(e.id);
kept.push({ e, i, t: time(e.at) });
});
kept.sort((a, b) => (a.t !== null && b.t !== null && a.t !== b.t ? a.t - b.t : a.i - b.i));
return kept.map((k) => k.e);
}
type Loose<T> = Partial<T> & Record<string, unknown>;
const str = (v: unknown) => (typeof v === 'string' ? v : undefined);
const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
/** The smallest lane no other active step holds. */
function freeLane(steps: TraceStep[], except: string): number {
const used = new Set(steps.filter((s) => s.id !== except && s.status === 'active' && s.lane !== undefined).map((s) => s.lane));
let lane = 0;
while (used.has(lane)) lane += 1;
return lane;
}
function mergeCalls(prev: ToolCall[] | undefined, next: unknown): ToolCall[] | undefined {
if (!Array.isArray(next)) return prev;
const out = [...(prev ?? [])];
for (const c of next as Loose<ToolCall>[]) {
if (!c || typeof c.id !== 'string') continue;
const at = out.findIndex((x) => x.id === c.id);
const merged: ToolCall = { ...(at >= 0 ? out[at] : { id: c.id, name: str(c.name) ?? c.id }), ...c } as ToolCall;
if (at >= 0) out[at] = merged;
else out.push(merged);
}
return out;
}
/** Applies one step change. `retry` marks execution.retried: the attempt moves on. */
function applyStep(view: TraceView, e: InteractionEvent, p: Loose<ExecutionStepPayload>, kind: 'progress' | 'retry' | 'fail'): TraceView {
const id = p.stepId as string;
const steps = [...view.steps];
const at = steps.findIndex((s) => s.id === id);
const prev: TraceStep = at >= 0 ? steps[at] : { id, label: id, status: 'pending' };
const status: StepStatus =
kind === 'fail' ? 'failed' : kind === 'retry' ? 'active' : ((['pending', 'active', 'done', 'failed'] as const).find((s) => s === p.status) ?? prev.status);
const step: TraceStep = { ...prev, status };
step.label = str(p.label) ?? prev.label;
const planStepId = str(p.planStepId) ?? (e.correlationId !== undefined && e.correlationId !== id ? e.correlationId : undefined);
if (planStepId !== undefined) step.planStepId = planStepId;
const maxAttempts = num(p.maxAttempts);
if (maxAttempts !== undefined) step.maxAttempts = maxAttempts;
const attempt = num(p.attempt);
if (kind === 'retry') step.attempt = attempt ?? (prev.attempt ?? 1) + 1;
else if (attempt !== undefined) step.attempt = attempt;
const error = str(p.error);
if (error !== undefined) step.error = error;
const calls = mergeCalls(prev.toolCalls, p.toolCalls);
if (calls) step.toolCalls = calls;
const lane = num(p.lane);
if (lane !== undefined) step.lane = lane;
if (status === 'active') {
if (step.startedAt === undefined) step.startedAt = e.at;
delete step.endedAt;
if (step.lane === undefined) step.lane = freeLane(steps, id);
}
if (status === 'done' || status === 'failed') step.endedAt = e.at;
if (status === 'done') delete step.error;
if (at >= 0) steps[at] = step;
else steps.push(step);
const state: TraceState = view.state === 'idle' && status === 'active' ? 'running' : view.state;
return { ...view, steps, state };
}
/** Folds one contract event into the view. Events outside `execution.*` change nothing. */
export function traceReducer(view: TraceView, e: InteractionEvent): TraceView {
if (!e.type.startsWith('execution.')) return view;
const p = (e.payload && typeof e.payload === 'object' ? e.payload : {}) as Loose<ExecutionStepPayload & ExecutionRunPayload>;
const statusText = str(p.statusText);
// The status line is the newest event's own text; an event without one hands it back to describeView.
let next: TraceView = { ...view, lastEventAt: e.at, statusText: statusText ?? '' };
const stepId = str(p.stepId);
switch (e.type) {
case 'execution.started':
next.state = 'running';
delete next.error;
break;
case 'execution.progress':
if (stepId) next = applyStep(next, e, p, 'progress');
break;
case 'execution.retried':
if (stepId) next = applyStep(next, e, p, 'retry');
else next.runAttempt = num(p.attempt) ?? view.runAttempt + 1;
if (next.state === 'error' || next.state === 'idle') next.state = 'running';
if (!stepId) delete next.error;
break;
case 'execution.paused':
if (next.state === 'running' || next.state === 'idle') {
next.state = 'paused';
next.pausedBy = e.actor;
}
break;
case 'execution.resumed':
if (next.state === 'paused') next.state = 'running';
delete next.pausedBy;
break;
case 'execution.completed':
next.state = 'done';
delete next.pausedBy;
break;
case 'execution.failed':
if (stepId) next = applyStep(next, e, p, 'fail');
next.state = 'error';
next.error = str(p.error) ?? next.error;
delete next.pausedBy;
break;
case 'execution.stopped':
next.state = 'stopped';
delete next.pausedBy;
break;
default:
// execution.deviated: the plan side shows it; the trace notes the time only.
break;
}
return next;
}
/** The whole log, ordered and folded. */
export function foldTrace(events: ReadonlyArray<InteractionEvent>, runId?: string): TraceView {
return orderEvents(events, runId).reduce(traceReducer, emptyView);
}
/** True when a running trace has had no event for `stallAfterMs`. */
export function isStalled(view: Pick<TraceView, 'state' | 'lastEventAt'>, now: number, stallAfterMs: number | null): boolean {
if (stallAfterMs === null || view.state !== 'running' || !view.lastEventAt) return false;
const last = time(view.lastEventAt);
return last !== null && now - last >= stallAfterMs;
}
/* ---------------- text the parts show by default ---------------- */
/** "Attempt 2 of 3" once a step has retried, "Failed after 3 attempts" when it ran out. */
export function attemptText(step: Pick<TraceStep, 'attempt' | 'maxAttempts' | 'status'>): string | null {
const n = step.attempt ?? 1;
if (step.status === 'failed' && step.maxAttempts !== undefined && n >= step.maxAttempts && n > 1) return `Failed after ${n} attempts`;
if (n <= 1) return null;
return step.maxAttempts !== undefined ? `Attempt ${n} of ${step.maxAttempts}` : `Attempt ${n}`;
}
/** "4.2 s" from start to end, or to `now` while the step runs. */
export function durationText(step: Pick<TraceStep, 'startedAt' | 'endedAt'>, now?: number): string | null {
const start = step.startedAt ? time(step.startedAt) : null;
const end = step.endedAt ? time(step.endedAt) : now ?? null;
if (start === null || end === null || end < start) return null;
const s = (end - start) / 1000;
return s < 60 ? `${s.toFixed(1)} s` : `${Math.floor(s / 60)} min ${Math.round(s % 60)} s`;
}
/** A status line for a trace whose events carry no statusText. */
export function describeView(view: Pick<TraceView, 'state' | 'steps' | 'error' | 'pausedBy'>): string {
const active = view.steps.filter((s) => s.status === 'active');
switch (view.state) {
case 'idle':
return 'Waiting to start.';
case 'running':
if (active.length > 1) return `Running ${active.length} steps at once.`;
return active.length === 1 ? `Running: ${active[0].label}.` : 'Running.';
case 'paused':
return view.pausedBy === 'human' ? 'Paused by you.' : 'Paused.';
case 'done':
return 'Done.';
case 'error':
return view.error ? `Failed: ${view.error}` : 'Failed.';
case 'stopped':
return 'Stopped.';
}
}
/* ---------------- snapshot → events ---------------- */
/** What the trace last showed: the run state and each step's status. */
export interface TraceSnapshot {
state: TraceState;
statusText: string;
steps: ReadonlyArray<Pick<TraceStep, 'id' | 'label' | 'status' | 'planStepId' | 'attempt' | 'maxAttempts' | 'error' | 'lane'>>;
}
/**
* The contract events between two renders of the trace (spec 2026-10-04 §1, §2).
* `prev` is null on mount, read as an idle run with every step pending.
* Run: idle|done → running is execution.started, error → running is
* execution.retried, paused → running is execution.resumed, → paused is
* execution.paused, → done is execution.completed, → error is
* execution.failed, → stopped is execution.stopped.
* Step: a higher `attempt` is execution.retried; any other status change is
* execution.progress. Both are correlated by the plan step id when the host
* linked one, else the trace step id.
*/
export function traceEvents(prev: TraceSnapshot | null, next: TraceSnapshot): EventDraft[] {
const before: TraceSnapshot = prev ?? { state: 'idle', statusText: '', steps: [] };
const out: EventDraft[] = [];
const run: ExecutionRunPayload = { state: next.state, statusText: next.statusText };
const ran = before.state !== next.state;
if (ran && next.state === 'running') {
const type = before.state === 'error' ? 'execution.retried' : before.state === 'paused' ? 'execution.resumed' : 'execution.started';
out.push({ type, actor: 'agent', payload: run });
}
const was = new Map(before.steps.map((st) => [st.id, st]));
for (const st of next.steps) {
const old = was.get(st.id);
const retried = (st.attempt ?? 1) > (old?.attempt ?? 1);
if (!retried && (old?.status ?? 'pending') === st.status) continue;
const payload: ExecutionStepPayload = { stepId: st.id, label: st.label, status: st.status };
if (st.planStepId !== undefined) payload.planStepId = st.planStepId;
if (st.attempt !== undefined) payload.attempt = st.attempt;
if (st.maxAttempts !== undefined) payload.maxAttempts = st.maxAttempts;
if (st.error !== undefined) payload.error = st.error;
if (st.lane !== undefined) payload.lane = st.lane;
out.push({ type: retried ? 'execution.retried' : 'execution.progress', actor: 'agent', correlationId: st.planStepId ?? st.id, payload });
}
if (ran && next.state === 'paused') out.push({ type: 'execution.paused', actor: 'agent', payload: run });
if (ran && next.state === 'done') out.push({ type: 'execution.completed', actor: 'agent', payload: run });
if (ran && next.state === 'error') out.push({ type: 'execution.failed', actor: 'agent', payload: run });
if (ran && next.state === 'stopped') out.push({ type: 'execution.stopped', actor: 'agent', payload: run });
return out;
}
/* ---------------- React ---------------- */
export interface ReplayControls {
/** How many events are applied, 0 to `total`. */
position: number;
total: number;
/** The last applied event, or null at the start. */
current: InteractionEvent | null;
playing: boolean;
step: () => void;
back: () => void;
restart: () => void;
toEnd: () => void;
toggle: () => void;
}
interface ProgressTraceContext {
state: TraceState;
steps: TraceStep[];
statusText: string;
cost: { tokens: number; usd?: number } | null;
zoom: Zoom;
setZoom: (z: Zoom) => void;
config: ProgressTraceConfig;
connection: Connection;
stalled: boolean;
lastEventAt: string | null;
now: number;
redact: Redact;
replay: ReplayControls | null;
}
const Ctx = React.createContext<ProgressTraceContext | null>(null);
export function useProgressTrace(): ProgressTraceContext {
const ctx = React.useContext(Ctx);
if (!ctx) throw new Error('ProgressTrace parts must be used inside <ProgressTrace.Root>');
return ctx;
}
export interface RootProps {
/** Snapshot mode: the run state. Ignored when `events` is set. */
state?: TraceState;
/** Snapshot mode: the steps. Ignored when `events` is set. */
steps?: TraceStep[];
/** The status line. In event mode it overrides the text the events carry. */
statusText?: string;
/** Event mode: the run's contract events, in any order. The trace folds `execution.*` and ignores the rest. */
events?: ReadonlyArray<InteractionEvent>;
/** With `events`: step through the log one event at a time instead of showing all of it. */
replay?: boolean;
/** The host's link to the agent. Default "connected". */
connection?: Connection;
/** Applied to each tool input and output before display. Default: `defaultRedact`. */
redact?: Redact;
cost?: { tokens: number; usd?: number } | null;
config?: Partial<ProgressTraceConfig>;
/** One agent run; stamped on every contract event, and filters `events` to that run. Defaults to a generated id. */
runId?: string;
/** Snapshot mode only: execution.started | resumed | retried | progress | paused | completed | failed | stopped. */
onEvent?: InteractionEventHandler;
children: React.ReactNode;
}
const NO_STEPS: TraceStep[] = [];
function Root({
state: snapState = 'idle',
steps: snapSteps = NO_STEPS,
statusText: hostText,
events,
replay = false,
connection = 'connected',
redact = defaultRedact,
cost = null,
config: partial,
runId,
onEvent,
children,
}: RootProps) {
const config = React.useMemo(() => ({ ...defaultConfig, ...partial }), [partial]);
const [zoom, setZoom] = React.useState<Zoom>(config.defaultZoom);
const fallbackRun = React.useId();
const run = runId ?? fallbackRun;
const emit = React.useRef(onEvent);
emit.current = onEvent;
// Event mode: order once, then fold all of it or, in replay, the first `position` events.
const ordered = React.useMemo(() => (events ? orderEvents(events, runId) : null), [events, runId]);
const [position, setPosition] = React.useState(0);
const [playing, setPlaying] = React.useState(false);
const total = ordered?.length ?? 0;
const shown = replay ? Math.min(position, total) : total;
const folded = React.useMemo(() => (ordered ? ordered.slice(0, shown).reduce(traceReducer, emptyView) : null), [ordered, shown]);
// Snapshot mode: derive the contract events from each change, and note when the last one came.
const prevTrace = React.useRef<TraceSnapshot | null>(null);
const [snapAt, setSnapAt] = React.useState<string | null>(null);
React.useEffect(() => {
if (events) return;
const next: TraceSnapshot = {
state: snapState,
statusText: hostText ?? '',
steps: snapSteps.map(({ id, label, status, planStepId, attempt, maxAttempts, error, lane }) => ({ id, label, status, planStepId, attempt, maxAttempts, error, lane })),
};
const drafts = traceEvents(prevTrace.current, next);
prevTrace.current = next;
if (drafts.length > 0) setSnapAt(new Date().toISOString());
for (const d of drafts) emit.current?.(createEvent(run, d));
}, [events, snapState, snapSteps, hostText, run]);
const state = folded ? folded.state : snapState;
const steps = folded ? folded.steps : snapSteps;
const lastEventAt = folded ? folded.lastEventAt : snapAt;
const statusText = hostText ?? (folded ? folded.statusText || describeView(folded) : '');
// A clock for the stall check, ticking only while a live run could stall.
const [now, setNow] = React.useState(() => Date.now());
const watch = !replay && state === 'running' && connection === 'connected' && config.stallAfterMs !== null;
React.useEffect(() => {
if (!watch) return;
setNow(Date.now());
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, [watch]);
const stalled = watch && isStalled({ state, lastEventAt }, now, config.stallAfterMs);
// Replay: play advances one event per interval and stops at the end.
React.useEffect(() => {
if (!replay || !playing) return;
if (shown >= total) {
setPlaying(false);
return;
}
const t = setTimeout(() => setPosition((p) => Math.min(p + 1, total)), config.replayIntervalMs);
return () => clearTimeout(t);
}, [replay, playing, shown, total, config.replayIntervalMs]);
const replayControls = React.useMemo<ReplayControls | null>(
() =>
replay && ordered
? {
position: shown,
total,
current: shown > 0 ? ordered[shown - 1] : null,
playing,
step: () => setPosition((p) => Math.min(p + 1, total)),
back: () => {
setPlaying(false);
setPosition((p) => Math.max(Math.min(p, total) - 1, 0));
},
restart: () => {
setPlaying(false);
setPosition(0);
},
toEnd: () => {
setPlaying(false);
setPosition(total);
},
toggle: () => {
if (!playing && shown >= total) setPosition(0);
setPlaying(!playing);
},
}
: null,
[replay, ordered, shown, total, playing],
);
const value = React.useMemo(
() => ({ state, steps, statusText, cost, zoom, setZoom, config, connection, stalled, lastEventAt, now, redact, replay: replayControls }),
[state, steps, statusText, cost, zoom, config, connection, stalled, lastEventAt, now, redact, replayControls],
);
const lanes = new Set(steps.filter((s) => s.status === 'active').map((s) => s.lane ?? 0)).size;
return (
<Ctx.Provider value={value}>
<div
role="log"
aria-label="Agent activity"
data-pattern="progress-trace"
data-part="root"
data-state={state}
data-zoom={zoom}
data-connection={connection}
data-stalled={stalled ? 'true' : 'false'}
data-concurrency={lanes}
data-replay={replay ? 'true' : undefined}
>
{children}
</div>
</Ctx.Provider>
);
}
/** One-line current activity — the glanceable layer. */
function StatusLine() {
const { statusText, state } = useProgressTrace();
return (
<p data-part="status" data-state={state} aria-live="polite">
{statusText}
</p>
);
}
/** The two-depth structure itself. */
function ZoomToggle(props: Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick' | 'children'>) {
const { zoom, setZoom } = useProgressTrace();
return (
<button
type="button"
data-part="zoom"
aria-pressed={zoom === 'inspect'}
onClick={() => setZoom(zoom === 'glance' ? 'inspect' : 'glance')}
{...props}
>
{zoom === 'glance' ? 'Inspect' : 'Glance'}
</button>
);
}
/** Completed and pending steps — visible at inspect depth. Each step carries its status, lane and attempt. */
function StepLog({ children }: { children?: (step: TraceStep) => React.ReactNode }) {
const { steps, zoom } = useProgressTrace();
if (zoom === 'glance') return null;
return (
<ol data-part="steps">
{steps.map((s) => (
<li key={s.id} data-part="step" data-status={s.status} data-lane={s.lane ?? 0} data-attempt={s.attempt ?? 1}>
{children ? children(s) : s.label}
</li>
))}
</ol>
);
}
/** Expandable tool call with inputs/outputs, redacted first. Absent when the vendor narrates instead. */
function ToolCallView({ call, step }: { call: ToolCall; step?: TraceStep }) {
const { config, zoom, steps, redact } = useProgressTrace();
if (!config.showToolIO || zoom === 'glance') return null;
const owner = step ?? steps.find((s) => s.toolCalls?.some((c) => c.id === call.id)) ?? { id: '', label: '', status: 'pending' };
const input = toolValueText(call.input, { step: owner, field: 'input' }, redact);
const output = toolValueText(call.output, { step: owner, field: 'output' }, redact);
return (
<details data-part="tool-call">
<summary>{call.name}</summary>
{input !== null && <pre data-part="tool-input">{input}</pre>}
{output !== null && <pre data-part="tool-output">{output}</pre>}
</details>
);
}
/** Tokens, time, or money consumed. Absent in narration-style traces. */
function CostMeter({ children }: { children?: (cost: { tokens: number; usd?: number }) => React.ReactNode }) {
const { cost, config } = useProgressTrace();
if (!config.showCost || !cost) return null;
return (
<div data-part="cost" aria-label="Resources consumed">
{children ? children(cost) : `${cost.tokens.toLocaleString()} tokens${cost.usd != null ? ` · $${cost.usd.toFixed(2)}` : ''}`}
</div>
);
}
export type Health = 'stalled' | 'disconnected' | 'reconnecting';
/** The default words for each health notice. `since` is the clock time of the last event. */
export function healthText(health: Health, since: string | null, quietSeconds: number): string {
switch (health) {
case 'disconnected':
return since ? `Connection lost. This shows what arrived up to ${since}.` : 'Connection lost.';
case 'reconnecting':
return 'Reconnecting. Steps that ran meanwhile appear when the connection returns.';
case 'stalled':
return `No update for ${quietSeconds} s. The agent may be stuck or waiting on something.`;
}
}
/** Says when the trace may be out of date: the link dropped, or a running agent went quiet. Renders nothing otherwise. */
function HealthNotice({ children }: { children?: (health: Health) => React.ReactNode }) {
const { connection, stalled, lastEventAt, now } = useProgressTrace();
const health: Health | null = connection !== 'connected' ? connection : stalled ? 'stalled' : null;
if (!health) return null;
const last = lastEventAt ? Date.parse(lastEventAt) : NaN;
const since = Number.isNaN(last) ? null : new Date(last).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const quiet = Number.isNaN(last) ? 0 : Math.max(0, Math.round((now - last) / 1000));
return (
<p data-part="health" data-health={health} role="status">
{children ? children(health) : healthText(health, since, quiet)}
</p>
);
}
/** Replay buttons and the position. Renders only in replay mode. */
function ReplayBar() {
const { replay } = useProgressTrace();
if (!replay) return null;
const { position, total, current, playing } = replay;
return (
<div data-part="replay" role="group" aria-label="Replay">
<button type="button" data-part="replay-restart" onClick={replay.restart} disabled={position === 0}>
Restart
</button>
<button type="button" data-part="replay-back" onClick={replay.back} disabled={position === 0}>
Back
</button>
<button type="button" data-part="replay-play" aria-pressed={playing} onClick={replay.toggle} disabled={total === 0}>
{playing ? 'Pause' : 'Play'}
</button>
<button type="button" data-part="replay-step" onClick={replay.step} disabled={position >= total}>
Next event
</button>
<output data-part="replay-position" aria-live="polite">
{`Event ${position} of ${total}${current ? `: ${current.type}` : ''}`}
</output>
</div>
);
}
export const ProgressTrace = Object.assign(Root, {
StatusLine,
ZoomToggle,
StepLog,
ToolCall: ToolCallView,
CostMeter,
HealthNotice,
Replay: ReplayBar,
});
patterns/progress-trace/progress-trace.tsx
/**
* ProgressTrace — styled reference implementation with vendor presets.
* Claude Code preset exposes raw tool I/O and cost; ChatGPT agent-mode preset
* narrates with no cost meter.
*/
import * as React from 'react';
import {
ProgressTrace as Core,
attemptText,
durationText,
useProgressTrace,
type Connection,
type ProgressTraceConfig,
type Redact,
type TraceState,
type TraceStep,
} from './progress-trace-core';
import type { InteractionEvent, InteractionEventHandler } from '@/lib/interaction-contract';
export interface ProgressTracePreset {
id: string;
label: string;
machineConfig: Partial<ProgressTraceConfig>;
}
export const presets: Record<'neutral' | 'anthropic' | 'openai', ProgressTracePreset> = {
neutral: {
id: 'neutral',
label: 'Neutral',
machineConfig: { showToolIO: true, showCost: true, defaultZoom: 'glance' },
},
anthropic: {
id: 'anthropic',
label: 'Claude Code transcript (recreation)',
machineConfig: { showToolIO: true, showCost: true, defaultZoom: 'inspect' },
},
openai: {
id: 'openai',
label: 'ChatGPT agent activity log (recreation)',
machineConfig: { showToolIO: false, showCost: false, defaultZoom: 'glance' },
},
};
export interface ProgressTraceProps {
preset?: keyof typeof presets;
/** Snapshot mode: the run state. Ignored when `events` is set. */
state?: TraceState;
/** Snapshot mode: the steps. Ignored when `events` is set. */
steps?: TraceStep[];
/** The status line. With `events` it overrides the text the events carry. */
statusText?: string;
/** Event mode: the run's Interaction Contract events, in any order. */
events?: ReadonlyArray<InteractionEvent>;
/** With `events`: step through the recorded log one event at a time. */
replay?: boolean;
/** The host's link to the agent. */
connection?: Connection;
/** Applied to each tool input and output before display. Default masks secret-looking keys. */
redact?: Redact;
cost?: { tokens: number; usd?: number } | null;
/** Overrides on top of the preset, e.g. `stallAfterMs`. */
config?: Partial<ProgressTraceConfig>;
/** One agent run; stamped on every contract event. */
runId?: string;
/** Interaction Contract events, in snapshot mode (see the Interaction Contract docs page). */
onEvent?: InteractionEventHandler;
}
function StepRow({ step }: { step: TraceStep }) {
const { now } = useProgressTrace();
const attempt = attemptText(step);
const duration = durationText(step, step.status === 'active' ? now : undefined);
return (
<div className="ah-step">
<span className="ah-step-label">{step.label}</span>
{(attempt || duration) && (
<span className="ah-step-meta">
{attempt && <span data-part="attempt">{attempt}</span>}
{duration && <span data-part="duration">{duration}</span>}
</span>
)}
{step.error && step.status !== 'done' && (
<span className="ah-step-error" data-part="error">
{step.status === 'failed' ? step.error : `Last attempt failed: ${step.error}`}
</span>
)}
{step.toolCalls?.map((c) => <Core.ToolCall key={c.id} call={c} step={step} />)}
</div>
);
}
export function ProgressTrace({
preset = 'neutral',
state,
steps,
statusText,
events,
replay,
connection,
redact,
cost,
config,
runId,
onEvent,
}: ProgressTraceProps) {
const p = presets[preset];
const merged = React.useMemo(() => ({ ...p.machineConfig, ...config }), [p, config]);
return (
<Core
state={state}
steps={steps}
statusText={statusText}
events={events}
replay={replay}
connection={connection}
redact={redact}
cost={cost}
config={merged}
runId={runId}
onEvent={onEvent}
>
<Skin preset={p.id} />
</Core>
);
}
function Skin({ preset }: { preset: string }) {
const { state } = useProgressTrace();
return (
<div className="ah-trace" data-preset={preset} data-state={state}>
<div className="ah-head">
<Core.StatusLine />
<Core.ZoomToggle />
</div>
<Core.HealthNotice />
<Core.StepLog>{(step) => <StepRow step={step} />}</Core.StepLog>
<Core.CostMeter />
<Core.Replay />
</div>
);
}
Give this to your coding agent (shadcn MCP: npx shadcn@latest mcp init --client claude):
Add the @agentsandhumans registry to components.json:
{
"registries": {
"@agentsandhumans": "https://agentsandhumans.ai/api/registry/{name}.json"
}
}
Then install progress-trace (npx shadcn@latest add @agentsandhumans/progress-trace) and wire it into the flow where your agent knowing what the agent is doing, how far along it is, and whether to step in.
Both sides watched each other work, and both adjusted. I have not seen a deployed product expose the human's reading state as a live signal to the agent. That is the gap this study names.
The monitor watched. The agent moved. The monitor missed. Designing a view is not designing oversight. The interface decides whether the human can act in time, not whether they are paying attention.
Ally showed its next move before making it, and players gave it more room to act. Confidence-signaling changed the human's relationship to the agent, not the outcome. That gap is the design surface.
The graph view changed when developers chose to redirect the agent, not whether they could. That is the design lever: the shape of the work display shifts the timing of human judgment.
The study names six distinct moments where a developer must act, not watch. Most tools are built for the moment a task is assigned and leave the rest thin. That gap is where trust breaks.
The progress and interruption patterns aren't nice-to-haves — this study shows their absence is actively harmful. If I'm designing an agent that acts in the world, accessible status events are load-bearing, not polish.
Chart unlocks as the corpus grows — n = 0 mentions across 0 quarters.
Anthropicstreaming transcript / verbose mode
OpenAIactivity log
Cognition (Devin)Devin's workspace
Tensions & Failure Modes
Transparency vs. noise: full traces bury the one step that matters
Streaming theater: activity display can perform progress that is not happening
Depth mismatch: terminals show everything, chat surfaces show almost nothing
What we are learning from the community
Readers who build with Progress add what they found: questions, use cases, critiques, other ways to solve it.
An editor reads each one before it shows here. Nothing here changes the pattern's status on its own.
This pattern has no contributions yet. The form below sends the first one.