Agent Run Card
A compact run card with the agent's current step and a timer, a segmented progress bar, and a list of finished steps that stacks up as it reads, edits, tests and pushes.
Reading checkout.test.ts
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
Check,
FileSearch,
FlaskConical,
GitBranch,
PencilLine,
SquareTerminal,
X,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
export type RunStepKind = "read" | "edit" | "test" | "command" | "push";
export type RunStepStatus = "pending" | "running" | "done" | "failed";
export interface RunStep {
id: string;
kind: RunStepKind;
/** Past-tense label shown once the step is finished, e.g. "Read checkout.ts". */
label: string;
/** Present-continuous label shown while the step runs, e.g. "Reading checkout.ts". */
activeLabel?: string;
/** A short result fact, e.g. "+12 −4", "24 passed", "1 failed". */
detail?: string;
status: RunStepStatus;
/** How long the step took, once finished. */
durationSeconds?: number;
}
export interface AgentRunCardProps {
/** What the run is for, e.g. "Fix flaky checkout test". */
title: string;
steps: RunStep[];
/** Total elapsed time of the run, ticked by the caller. */
elapsedSeconds: number;
/** Shown as the headline once every step is finished. */
doneLabel?: string;
/** How many finished steps to show before collapsing older ones. */
maxVisible?: number;
className?: string;
}
const KIND_ICON: Record<RunStepKind, LucideIcon> = {
read: FileSearch,
edit: PencilLine,
test: FlaskConical,
command: SquareTerminal,
push: GitBranch,
};
export function formatTimer(seconds: number) {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
export function AgentRunCard({
title,
steps,
elapsedSeconds,
doneLabel = "Run complete",
maxVisible = 4,
className,
}: AgentRunCardProps) {
const reduceMotion = useReducedMotion();
const [showAll, setShowAll] = React.useState(false);
const current = steps.find((s) => s.status === "running");
const finished = steps.filter((s) => s.status === "done" || s.status === "failed");
const complete = !current && steps.length > 0 && steps.every((s) => s.status !== "pending");
// Newest first, so the list stacks up from the top as the agent works.
const history = [...finished].reverse();
const visible = showAll ? history : history.slice(0, maxVisible);
const hidden = history.length - visible.length;
const headline = current
? current.activeLabel ?? current.label
: complete
? doneLabel
: "Waiting to start";
return (
<section
aria-label={`Agent run: ${title}`}
className={cn("w-full overflow-hidden rounded-2xl border bg-card text-card-foreground", className)}
>
<div className="px-4 pb-3 pt-3.5">
{/* Title + timer */}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<RunGlyph complete={complete} running={Boolean(current)} />
<span className="min-w-0 flex-1 truncate">{title}</span>
<span className="font-mono tabular-nums" aria-hidden>
{formatTimer(elapsedSeconds)}
</span>
<span className="sr-only">Elapsed {formatTimer(elapsedSeconds)}</span>
</div>
{/* Current step — the only live region, so the ticking timer stays quiet */}
<div className="relative mt-1.5 h-5 overflow-hidden" aria-live="polite" aria-atomic>
<AnimatePresence initial={false} mode="popLayout">
<motion.p
key={current?.id ?? (complete ? "done" : "idle")}
initial={reduceMotion ? { opacity: 0 } : { y: 14, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={reduceMotion ? { opacity: 0 } : { y: -14, opacity: 0 }}
transition={{ type: "spring", stiffness: 420, damping: 34 }}
className="truncate text-sm font-medium leading-5"
>
{headline}
</motion.p>
</AnimatePresence>
</div>
<SegmentedProgress steps={steps} />
</div>
{/* Finished steps */}
{history.length > 0 && (
<div className="border-t px-2 py-1.5">
<ol aria-label={`Finished steps, ${finished.length} of ${steps.length}`} className="flex flex-col">
<AnimatePresence initial={false}>
{visible.map((step) => (
<motion.li
key={step.id}
layout={reduceMotion ? false : "position"}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, height: 0, y: -6 }}
animate={{ opacity: 1, height: "auto", y: 0 }}
exit={{ opacity: 0, height: 0 }}
transition={{ type: "spring", stiffness: 380, damping: 34 }}
className="overflow-hidden"
>
<StepRow step={step} />
</motion.li>
))}
</AnimatePresence>
</ol>
{history.length > maxVisible && (
<button
type="button"
onClick={() => setShowAll((v) => !v)}
aria-expanded={showAll}
className="mt-0.5 w-full rounded-md px-2 py-1 text-left text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{showAll ? "Show fewer" : `${hidden} earlier ${hidden === 1 ? "step" : "steps"}`}
</button>
)}
</div>
)}
</section>
);
}
function StepRow({ step }: { step: RunStep }) {
const Icon = KIND_ICON[step.kind];
const failed = step.status === "failed";
return (
<div className="flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm">
<span className="relative flex size-4 shrink-0 items-center justify-center text-muted-foreground">
<Icon className="size-3.5" aria-hidden />
<span
className={cn(
"absolute -bottom-0.5 -right-1 flex size-2.5 items-center justify-center rounded-full ring-2 ring-card",
failed ? "bg-destructive" : "bg-muted-foreground"
)}
aria-hidden
>
{failed ? (
<X className="size-2 text-background" strokeWidth={4} />
) : (
<Check className="size-2 text-background" strokeWidth={4} />
)}
</span>
</span>
<span className="sr-only">{failed ? "Failed: " : "Done: "}</span>
<span className="min-w-0 flex-1 truncate text-foreground/80">{step.label}</span>
{step.detail && (
<span
className={cn(
"shrink-0 font-mono text-xs tabular-nums",
failed ? "text-destructive" : "text-muted-foreground"
)}
>
{step.detail}
</span>
)}
{step.durationSeconds !== undefined && (
<span className="w-9 shrink-0 text-right font-mono text-xs tabular-nums text-muted-foreground/70">
{step.durationSeconds < 60 ? `${step.durationSeconds}s` : formatTimer(step.durationSeconds)}
</span>
)}
</div>
);
}
function SegmentedProgress({ steps }: { steps: RunStep[] }) {
const reduceMotion = useReducedMotion();
const done = steps.filter((s) => s.status === "done" || s.status === "failed").length;
return (
<div
role="progressbar"
aria-label="Run progress"
aria-valuemin={0}
aria-valuemax={steps.length}
aria-valuenow={done}
aria-valuetext={`${done} of ${steps.length} steps finished`}
className="mt-3 flex h-1.5 gap-1"
>
<AnimatePresence initial={false}>
{steps.map((step) => (
<motion.span
key={step.id}
layout={!reduceMotion}
initial={{ opacity: 0, flexGrow: 0 }}
animate={{ opacity: 1, flexGrow: 1 }}
transition={{ type: "spring", stiffness: 300, damping: 32 }}
className="relative h-full basis-0 overflow-hidden rounded-full bg-muted"
>
<motion.span
className={cn(
"absolute inset-0 origin-left rounded-full",
step.status === "failed" ? "bg-destructive/80" : "bg-foreground/80"
)}
initial={false}
animate={{ scaleX: step.status === "done" || step.status === "failed" ? 1 : 0 }}
transition={{ duration: reduceMotion ? 0 : 0.45, ease: [0.22, 1, 0.36, 1] }}
/>
{step.status === "running" &&
(reduceMotion ? (
<span className="absolute inset-y-0 left-0 w-1/2 rounded-full bg-foreground/35" />
) : (
<motion.span
className="absolute inset-y-0 w-1/2 rounded-full bg-gradient-to-r from-transparent via-foreground/50 to-transparent"
initial={{ left: "-50%" }}
animate={{ left: "100%" }}
transition={{ duration: 1.1, ease: "easeInOut", repeat: Infinity }}
/>
))}
</motion.span>
))}
</AnimatePresence>
</div>
);
}
function RunGlyph({ running, complete }: { running: boolean; complete: boolean }) {
const reduceMotion = useReducedMotion();
return (
<span className="relative flex size-3.5 shrink-0 items-center justify-center" aria-hidden>
<AnimatePresence initial={false} mode="popLayout">
{complete ? (
<motion.span
key="done"
initial={{ scale: 0.4, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 420, damping: 22 }}
className="flex size-3.5 items-center justify-center rounded-full bg-foreground"
>
<Check className="size-2.5 text-background" strokeWidth={3.5} />
</motion.span>
) : (
<motion.svg
key="spin"
viewBox="0 0 16 16"
fill="none"
className="size-3.5 text-foreground"
initial={{ opacity: 0 }}
animate={running && !reduceMotion ? { opacity: 1, rotate: 360 } : { opacity: running ? 1 : 0.4 }}
exit={{ opacity: 0, scale: 0.4 }}
transition={
running && !reduceMotion
? { rotate: { duration: 0.9, ease: "linear", repeat: Infinity }, opacity: { duration: 0.2 } }
: { duration: 0.2 }
}
>
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity={0.18} strokeWidth="2" />
<path d="M8 2a6 6 0 0 1 6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</motion.svg>
)}
</AnimatePresence>
</span>
);
}
A UX spec for this pattern — written for agents implementing or reusing it, not the code.
# Agent Run Card
## Summary
A compact status card for a single agent run. The header names the run and shows a running timer; below it, the current step reads as a present-continuous headline ("Running checkout tests"), a segmented progress bar shows one segment per planned step, and a list of finished steps stacks up underneath — newest on top — as the agent reads files, edits code, runs tests and pushes a fix. Each finished row carries a kind icon, a past-tense label, a short result fact and how long it took.
## When to use
- An agent is working through a concrete, tool-driven run (a bug fix, a migration, a code change) and the user wants a glanceable "where is it now, how long has it been, what has it done" view.
- The run can live inline in a conversation, a PR, a notification or a dashboard tile where space is tight.
- Steps are discrete actions with a result worth one fact: lines read, a diff size, a test count, a commit hash.
## When not to use
- The agent's plan, written up front as outcomes rather than actions — use Task List.
- The full, inspectable log of reasoning and tool calls — use Expandable Trace; the card summarises, it doesn't replace the trace.
- Several runs at once in a side panel with stop controls — use Background Tasks Drawer.
- Parallel sub-agents, each with their own steps — use Multi-Agent Trace.
- A single short operation with no meaningful steps — use Thinking Loader or Tool Call Chip.
## Anatomy
- Container: a bordered, rounded card, sized for a narrow column.
- Header row: a run glyph (spinning arc while running, filled check when complete), the run title (muted, truncated) and a monospace timer (`m:ss`) aligned right.
- Current step: a single-line, medium-weight headline using the step's present-continuous label; swaps to a done label when the run finishes.
- Segmented progress bar: one segment per step in the plan. Finished segments fill solid; a failed one fills in the destructive color; the running segment shows a light sweeping across it; pending segments are an empty track.
- Finished steps list: separated by a divider, newest first. Each row has a kind icon (read, edit, test, command, push) badged with a small check or cross, a past-tense label, an optional result fact in monospace, and the step's duration.
- Overflow control: after a handful of rows, older ones collapse behind "N earlier steps".
## Behavior
- The timer ticks once per second while the run is active and stops on completion; it shows total run time, not per-step time.
- When a step finishes, its segment fills left to right, the headline slides up to the next step, and the finished row drops into the top of the list with a short height-and-fade spring, pushing older rows down.
- The agent can revise its plan mid-run. After a failing test, a fix and a re-run are inserted before the remaining steps: new segments grow into the bar and existing ones re-flow, without resetting finished ones.
- A failed step stays in the history with a cross badge and its failure fact ("1 failed") — a later success doesn't erase it.
- Steps run one at a time; at most one segment shows the running sweep.
- When every step is finished, the glyph pops to a check, the headline shows the done label, and the card stays as a record of the run.
- Rows are summaries; if the product has a trace, a row may link to that step's detail, but the card itself stays read-only.
## Content guidelines
- Run title: what the run is for, as an imperative outcome — "Fix flaky checkout test", not "Agent run #42".
- Current step: present continuous, naming the object — "Reading cart.ts", "Running checkout tests".
- Finished rows: past tense of the same action — "Read cart.ts", "Edited cart.ts", "Pushed fix/checkout-rounding".
- Result facts: one short, scannable fact per row — "212 lines", "+9 −3", "24 passed", "1 failed", a short commit hash. Omit rather than pad.
- Durations: whole seconds under a minute ("8s"), `m:ss` beyond.
## Accessibility
- The card is a `<section>` named after the run ("Agent run: Fix flaky checkout test").
- Only the current-step headline is a polite live region, so step changes are announced while the ticking timer stays silent; the timer's visible text is `aria-hidden` with an equivalent visually hidden label.
- The segmented bar is a `role="progressbar"` with `aria-valuenow`/`aria-valuemax` in steps and an `aria-valuetext` such as "3 of 7 steps finished".
- The finished list is an ordered list whose name includes progress; each row prefixes visually hidden "Done:" or "Failed:" — the badge is decorative.
- The overflow toggle is a real button with `aria-expanded`.
- Failure is never color-only: failed rows show a cross badge and a textual fact.
- Respect `prefers-reduced-motion`: no spinning glyph or sweeping segment (a static partial fill marks the running step), and rows and headlines cross-fade instead of sliding.
## Related patterns
- **Task List** — Use it as well when the agent shares its plan up front, so the plan shows outcomes and this card shows the actions taken.
- **Expandable Trace** — Use it as well when someone needs the detailed log behind each finished row after the run ends.
- **Background Tasks Drawer** — Use it instead when people need to monitor and stop several runs at once rather than follow one.
- **Tool Call Chip** — Use it instead when the run is a single tool call that fits inline in a message.
- **Terminal Stream** — Use it as well when a command step's raw output matters and should be readable as it streams.
Do’s and don’ts
Do use it for
- An agent is working through a concrete, tool-driven run (a bug fix, a migration, a code change) and the user wants a glanceable "where is it now, how long has it been, what has it done" view.
- The run can live inline in a conversation, a PR, a notification or a dashboard tile where space is tight.
- Steps are discrete actions with a result worth one fact: lines read, a diff size, a test count, a commit hash.
Don’t use it for
- The agent's plan, written up front as outcomes rather than actions — use Task List.
- The full, inspectable log of reasoning and tool calls — use Expandable Trace; the card summarises, it doesn't replace the trace.
- Several runs at once in a side panel with stop controls — use Background Tasks Drawer.
- Parallel sub-agents, each with their own steps — use Multi-Agent Trace.
- A single short operation with no meaningful steps — use Thinking Loader or Tool Call Chip.
Related patterns
Task List
Use it as well when the agent shares its plan up front, so the plan shows outcomes and this card shows the actions taken.
Expandable Trace
Use it as well when someone needs the detailed log behind each finished row after the run ends.
Background Tasks Drawer
Use it instead when people need to monitor and stop several runs at once rather than follow one.
Tool Call Chip
Use it instead when the run is a single tool call that fits inline in a message.
Terminal Stream
Use it as well when a command step's raw output matters and should be readable as it streams.