Understanding

Progress

Solves: knowing what the agent is doing, how far along it is, and whether to step in.

Established — a stable convention across independent vendors.

pattern: established code: beta <ProgressTrace>: Beta: the code works and has tests, and its API can still change.

watching →emerging →established →contested →fading 9 sightings since Apr 2026 seen in 1 different contexts
Ask an assistant about this pattern Ask ChatGPT ↗Ask Claude ↗

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
human agent system tools · files · spend meter calls tools, reads files, runs commands returns output, errors and spend streams status, steps, tool calls and cost glances, or zooms into one step intervenes if needed
02 · who holds what
human interface agent Status line: One line on what the AI is doing right now 01 Status line Step log: The steps done and the steps still to come 02 Step log Tool calls: Each action the AI took, with what went in and what came out 03 Tool calls Cost meter: Time, money, or tokens used so far 04 Cost meter Glance vs. inspect: Two depths: a quick look for comfort, a full log for doubt 05 Glance vs. inspect
human interface agent Status line: One line on what the AI is doing right now 01 Status line Step log: The steps done and the steps still to come 02 Step log Tool calls: Each action the AI took, with what went in and what came out 03 Tool calls Cost meter: Time, money, or tokens used so far 04 Cost meter Glance vs. inspect: Two depths: a quick look for comfort, a full log for doubt 05 Glance vs. inspect
  1. 01 Status line One line on what the AI is doing right now
  2. 02 Step log The steps done and the steps still to come
  3. 03 Tool calls Each action the AI took, with what went in and what came out
  4. 04 Cost meter Time, money, or tokens used so far
  5. 05 Glance vs. inspect Two 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):

{
  "registries": {
    "@agentsandhumans": "https://agentsandhumans.ai/api/registry/{name}.json"
  }
}
npx shadcn@latest add @agentsandhumans/progress-trace

Or install directly from the endpoint:

npx shadcn@latest add https://agentsandhumans.ai/api/registry/progress-trace.json

Dependencies: react · Files: patterns/progress-trace/progress-trace-core.tsx · patterns/progress-trace/progress-trace.tsx · patterns/tokens.css

patterns/progress-trace/progress-trace-core.tsx
/**
 * 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.

Field Notes

OCT 8 · Paper · via arXiv ContextInterruptionProgress

Ream gives agents and researchers a shared view of each other's work

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.

OCT 6 · Paper · via arXiv ApprovalInterruptionProgress

Monitors missed harmful AI actions 63% of the time in new study

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.

SEP 26 · Paper · via arXiv Agent IdentityConfidence SignalingProgress

PUBG Ally shipped to 141 countries and measured what broke trust

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.

SEP 22 · Paper · via arXiv ObservabilityProgressRecovery

Vibe-GUIDE gives developers a graph view of agent coding work

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.

SEP 22 · Paper · via arXiv InterruptionProgressRecovery

19 developers mapped the real work of supervising a coding agent

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.

SEP 2 · Paper · via arXiv Confidence SignalingInterruptionProgress

Blind participants exposed what computer-use agents still cannot do

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.

Documented sightings (4) — anatomy-mapped observations from vendor materials

A run-logger captures per-session activity so developers can track what each agent has done without interrupting the agent's execution flow.

arXiv · Sep 29, 2026

Interviews revealed 'beyond-automation needs' — users needed to understand and monitor agent progress in ways the current prototype did not support.

arXiv · Sep 2, 2026
Anthropic

Terminal-depth trace: spinner for glance, transcript with expandable tool calls for inspection, token/cost accounting throughout.

Anthropic docs · Apr 1, 2026
OpenAI

Narrated progress: collapsible activity log with screenshots — optimized for glance, thinner on raw inspection.

OpenAI help center · Apr 1, 2026

Side by Side

Anatomy part AnthropicOpenAI
Status line spinner with current activity verbone-line narration of current step
Step log transcript of turnscollapsible activity log
Tool calls expandable tool calls with inputs/outputsscreenshots of intermediate states
Cost meter token and cost accounting—
Glance vs. inspect spinner-to-transcript depth switchcollapse/expand log

What Vendors Call It

Chart unlocks as the corpus grows — n = 0 mentions across 0 quarters.

  • Anthropic streaming transcript / verbose mode
  • OpenAI activity 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.

Add to this pattern

A post, a comment, a thread or a repo. Leave it empty to write it here.

Or the name you post under. It shows beside your contribution.

We store a one-way hash of it, not the address.

Up to 2,000 characters. Leave it empty if the link says it all.

The Story So Far

  1. Oct 4, 2026 drift: too-early → rising (30d sightings 2 → 3)
  2. Oct 3, 2026 drift: new → too-early (30d sightings 3 → 2)
  3. Sep 22, 2026 sighting-story Vibe-GUIDE's graph moved when developers redirected, not watched A study of 19 developers and 73,000 posts points to the same finding: the display of work shapes when a human decides to act. Most tools set that timing too late.
  4. Aug 30, 2026 Seeded from SPEC v1.5 Phase 0: Anthropic-first corpus with OpenAI counterparts

Related Patterns