{"slug":"agent-run-card","category":"agents","job":"oversee","added":"2026-11-05T14:21Z","title":"Agent Run Card","description":"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.","url":"https://ai-patterns.app/patterns/agents/agent-run-card","markdown":"https://ai-patterns.app/patterns/agents/agent-run-card.md","json":"https://ai-patterns.app/api/patterns/agents/agent-run-card","seenIn":[],"spec":"# Agent Run Card\n\n## Summary\nA 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.\n\n## When to use\n- 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.\n- The run can live inline in a conversation, a PR, a notification or a dashboard tile where space is tight.\n- Steps are discrete actions with a result worth one fact: lines read, a diff size, a test count, a commit hash.\n\n## When not to use\n- The agent's plan, written up front as outcomes rather than actions — use Task List.\n- The full, inspectable log of reasoning and tool calls — use Expandable Trace; the card summarises, it doesn't replace the trace.\n- Several runs at once in a side panel with stop controls — use Background Tasks Drawer.\n- Parallel sub-agents, each with their own steps — use Multi-Agent Trace.\n- A single short operation with no meaningful steps — use Thinking Loader or Tool Call Chip.\n\n## Anatomy\n- Container: a bordered, rounded card, sized for a narrow column.\n- 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.\n- Current step: a single-line, medium-weight headline using the step's present-continuous label; swaps to a done label when the run finishes.\n- 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.\n- 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.\n- Overflow control: after a handful of rows, older ones collapse behind \"N earlier steps\".\n\n## Behavior\n- The timer ticks once per second while the run is active and stops on completion; it shows total run time, not per-step time.\n- 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.\n- 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.\n- A failed step stays in the history with a cross badge and its failure fact (\"1 failed\") — a later success doesn't erase it.\n- Steps run one at a time; at most one segment shows the running sweep.\n- 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.\n- 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.\n\n## Content guidelines\n- Run title: what the run is for, as an imperative outcome — \"Fix flaky checkout test\", not \"Agent run #42\".\n- Current step: present continuous, naming the object — \"Reading cart.ts\", \"Running checkout tests\".\n- Finished rows: past tense of the same action — \"Read cart.ts\", \"Edited cart.ts\", \"Pushed fix/checkout-rounding\".\n- Result facts: one short, scannable fact per row — \"212 lines\", \"+9 −3\", \"24 passed\", \"1 failed\", a short commit hash. Omit rather than pad.\n- Durations: whole seconds under a minute (\"8s\"), `m:ss` beyond.\n\n## Accessibility\n- The card is a `<section>` named after the run (\"Agent run: Fix flaky checkout test\").\n- 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.\n- 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\".\n- The finished list is an ordered list whose name includes progress; each row prefixes visually hidden \"Done:\" or \"Failed:\" — the badge is decorative.\n- The overflow toggle is a real button with `aria-expanded`.\n- Failure is never color-only: failed rows show a cross badge and a textual fact.\n- 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.\n\n## Related patterns\n- **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.\n- **Expandable Trace** — Use it as well when someone needs the detailed log behind each finished row after the run ends.\n- **Background Tasks Drawer** — Use it instead when people need to monitor and stop several runs at once rather than follow one.\n- **Tool Call Chip** — Use it instead when the run is a single tool call that fits inline in a message.\n- **Terminal Stream** — Use it as well when a command step's raw output matters and should be readable as it streams.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  Check,\n  FileSearch,\n  FlaskConical,\n  GitBranch,\n  PencilLine,\n  SquareTerminal,\n  X,\n  type LucideIcon,\n} from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type RunStepKind = \"read\" | \"edit\" | \"test\" | \"command\" | \"push\";\nexport type RunStepStatus = \"pending\" | \"running\" | \"done\" | \"failed\";\n\nexport interface RunStep {\n  id: string;\n  kind: RunStepKind;\n  /** Past-tense label shown once the step is finished, e.g. \"Read checkout.ts\". */\n  label: string;\n  /** Present-continuous label shown while the step runs, e.g. \"Reading checkout.ts\". */\n  activeLabel?: string;\n  /** A short result fact, e.g. \"+12 −4\", \"24 passed\", \"1 failed\". */\n  detail?: string;\n  status: RunStepStatus;\n  /** How long the step took, once finished. */\n  durationSeconds?: number;\n}\n\nexport interface AgentRunCardProps {\n  /** What the run is for, e.g. \"Fix flaky checkout test\". */\n  title: string;\n  steps: RunStep[];\n  /** Total elapsed time of the run, ticked by the caller. */\n  elapsedSeconds: number;\n  /** Shown as the headline once every step is finished. */\n  doneLabel?: string;\n  /** How many finished steps to show before collapsing older ones. */\n  maxVisible?: number;\n  className?: string;\n}\n\nconst KIND_ICON: Record<RunStepKind, LucideIcon> = {\n  read: FileSearch,\n  edit: PencilLine,\n  test: FlaskConical,\n  command: SquareTerminal,\n  push: GitBranch,\n};\n\nexport function formatTimer(seconds: number) {\n  const m = Math.floor(seconds / 60);\n  const s = Math.floor(seconds % 60);\n  return `${m}:${s.toString().padStart(2, \"0\")}`;\n}\n\nexport function AgentRunCard({\n  title,\n  steps,\n  elapsedSeconds,\n  doneLabel = \"Run complete\",\n  maxVisible = 4,\n  className,\n}: AgentRunCardProps) {\n  const reduceMotion = useReducedMotion();\n  const [showAll, setShowAll] = React.useState(false);\n\n  const current = steps.find((s) => s.status === \"running\");\n  const finished = steps.filter((s) => s.status === \"done\" || s.status === \"failed\");\n  const complete = !current && steps.length > 0 && steps.every((s) => s.status !== \"pending\");\n\n  // Newest first, so the list stacks up from the top as the agent works.\n  const history = [...finished].reverse();\n  const visible = showAll ? history : history.slice(0, maxVisible);\n  const hidden = history.length - visible.length;\n\n  const headline = current\n    ? current.activeLabel ?? current.label\n    : complete\n      ? doneLabel\n      : \"Waiting to start\";\n\n  return (\n    <section\n      aria-label={`Agent run: ${title}`}\n      className={cn(\"w-full overflow-hidden rounded-2xl border bg-card text-card-foreground\", className)}\n    >\n      <div className=\"px-4 pb-3 pt-3.5\">\n        {/* Title + timer */}\n        <div className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n          <RunGlyph complete={complete} running={Boolean(current)} />\n          <span className=\"min-w-0 flex-1 truncate\">{title}</span>\n          <span className=\"font-mono tabular-nums\" aria-hidden>\n            {formatTimer(elapsedSeconds)}\n          </span>\n          <span className=\"sr-only\">Elapsed {formatTimer(elapsedSeconds)}</span>\n        </div>\n\n        {/* Current step — the only live region, so the ticking timer stays quiet */}\n        <div className=\"relative mt-1.5 h-5 overflow-hidden\" aria-live=\"polite\" aria-atomic>\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.p\n              key={current?.id ?? (complete ? \"done\" : \"idle\")}\n              initial={reduceMotion ? { opacity: 0 } : { y: 14, opacity: 0 }}\n              animate={{ y: 0, opacity: 1 }}\n              exit={reduceMotion ? { opacity: 0 } : { y: -14, opacity: 0 }}\n              transition={{ type: \"spring\", stiffness: 420, damping: 34 }}\n              className=\"truncate text-sm font-medium leading-5\"\n            >\n              {headline}\n            </motion.p>\n          </AnimatePresence>\n        </div>\n\n        <SegmentedProgress steps={steps} />\n      </div>\n\n      {/* Finished steps */}\n      {history.length > 0 && (\n        <div className=\"border-t px-2 py-1.5\">\n          <ol aria-label={`Finished steps, ${finished.length} of ${steps.length}`} className=\"flex flex-col\">\n            <AnimatePresence initial={false}>\n              {visible.map((step) => (\n                <motion.li\n                  key={step.id}\n                  layout={reduceMotion ? false : \"position\"}\n                  initial={reduceMotion ? { opacity: 0 } : { opacity: 0, height: 0, y: -6 }}\n                  animate={{ opacity: 1, height: \"auto\", y: 0 }}\n                  exit={{ opacity: 0, height: 0 }}\n                  transition={{ type: \"spring\", stiffness: 380, damping: 34 }}\n                  className=\"overflow-hidden\"\n                >\n                  <StepRow step={step} />\n                </motion.li>\n              ))}\n            </AnimatePresence>\n          </ol>\n          {history.length > maxVisible && (\n            <button\n              type=\"button\"\n              onClick={() => setShowAll((v) => !v)}\n              aria-expanded={showAll}\n              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\"\n            >\n              {showAll ? \"Show fewer\" : `${hidden} earlier ${hidden === 1 ? \"step\" : \"steps\"}`}\n            </button>\n          )}\n        </div>\n      )}\n    </section>\n  );\n}\n\nfunction StepRow({ step }: { step: RunStep }) {\n  const Icon = KIND_ICON[step.kind];\n  const failed = step.status === \"failed\";\n\n  return (\n    <div className=\"flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm\">\n      <span className=\"relative flex size-4 shrink-0 items-center justify-center text-muted-foreground\">\n        <Icon className=\"size-3.5\" aria-hidden />\n        <span\n          className={cn(\n            \"absolute -bottom-0.5 -right-1 flex size-2.5 items-center justify-center rounded-full ring-2 ring-card\",\n            failed ? \"bg-destructive\" : \"bg-muted-foreground\"\n          )}\n          aria-hidden\n        >\n          {failed ? (\n            <X className=\"size-2 text-background\" strokeWidth={4} />\n          ) : (\n            <Check className=\"size-2 text-background\" strokeWidth={4} />\n          )}\n        </span>\n      </span>\n      <span className=\"sr-only\">{failed ? \"Failed: \" : \"Done: \"}</span>\n      <span className=\"min-w-0 flex-1 truncate text-foreground/80\">{step.label}</span>\n      {step.detail && (\n        <span\n          className={cn(\n            \"shrink-0 font-mono text-xs tabular-nums\",\n            failed ? \"text-destructive\" : \"text-muted-foreground\"\n          )}\n        >\n          {step.detail}\n        </span>\n      )}\n      {step.durationSeconds !== undefined && (\n        <span className=\"w-9 shrink-0 text-right font-mono text-xs tabular-nums text-muted-foreground/70\">\n          {step.durationSeconds < 60 ? `${step.durationSeconds}s` : formatTimer(step.durationSeconds)}\n        </span>\n      )}\n    </div>\n  );\n}\n\nfunction SegmentedProgress({ steps }: { steps: RunStep[] }) {\n  const reduceMotion = useReducedMotion();\n  const done = steps.filter((s) => s.status === \"done\" || s.status === \"failed\").length;\n\n  return (\n    <div\n      role=\"progressbar\"\n      aria-label=\"Run progress\"\n      aria-valuemin={0}\n      aria-valuemax={steps.length}\n      aria-valuenow={done}\n      aria-valuetext={`${done} of ${steps.length} steps finished`}\n      className=\"mt-3 flex h-1.5 gap-1\"\n    >\n      <AnimatePresence initial={false}>\n        {steps.map((step) => (\n          <motion.span\n            key={step.id}\n            layout={!reduceMotion}\n            initial={{ opacity: 0, flexGrow: 0 }}\n            animate={{ opacity: 1, flexGrow: 1 }}\n            transition={{ type: \"spring\", stiffness: 300, damping: 32 }}\n            className=\"relative h-full basis-0 overflow-hidden rounded-full bg-muted\"\n          >\n            <motion.span\n              className={cn(\n                \"absolute inset-0 origin-left rounded-full\",\n                step.status === \"failed\" ? \"bg-destructive/80\" : \"bg-foreground/80\"\n              )}\n              initial={false}\n              animate={{ scaleX: step.status === \"done\" || step.status === \"failed\" ? 1 : 0 }}\n              transition={{ duration: reduceMotion ? 0 : 0.45, ease: [0.22, 1, 0.36, 1] }}\n            />\n            {step.status === \"running\" &&\n              (reduceMotion ? (\n                <span className=\"absolute inset-y-0 left-0 w-1/2 rounded-full bg-foreground/35\" />\n              ) : (\n                <motion.span\n                  className=\"absolute inset-y-0 w-1/2 rounded-full bg-gradient-to-r from-transparent via-foreground/50 to-transparent\"\n                  initial={{ left: \"-50%\" }}\n                  animate={{ left: \"100%\" }}\n                  transition={{ duration: 1.1, ease: \"easeInOut\", repeat: Infinity }}\n                />\n              ))}\n          </motion.span>\n        ))}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nfunction RunGlyph({ running, complete }: { running: boolean; complete: boolean }) {\n  const reduceMotion = useReducedMotion();\n\n  return (\n    <span className=\"relative flex size-3.5 shrink-0 items-center justify-center\" aria-hidden>\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {complete ? (\n          <motion.span\n            key=\"done\"\n            initial={{ scale: 0.4, opacity: 0 }}\n            animate={{ scale: 1, opacity: 1 }}\n            transition={{ type: \"spring\", stiffness: 420, damping: 22 }}\n            className=\"flex size-3.5 items-center justify-center rounded-full bg-foreground\"\n          >\n            <Check className=\"size-2.5 text-background\" strokeWidth={3.5} />\n          </motion.span>\n        ) : (\n          <motion.svg\n            key=\"spin\"\n            viewBox=\"0 0 16 16\"\n            fill=\"none\"\n            className=\"size-3.5 text-foreground\"\n            initial={{ opacity: 0 }}\n            animate={running && !reduceMotion ? { opacity: 1, rotate: 360 } : { opacity: running ? 1 : 0.4 }}\n            exit={{ opacity: 0, scale: 0.4 }}\n            transition={\n              running && !reduceMotion\n                ? { rotate: { duration: 0.9, ease: \"linear\", repeat: Infinity }, opacity: { duration: 0.2 } }\n                : { duration: 0.2 }\n            }\n          >\n            <circle cx=\"8\" cy=\"8\" r=\"6\" stroke=\"currentColor\" strokeOpacity={0.18} strokeWidth=\"2\" />\n            <path d=\"M8 2a6 6 0 0 1 6 6\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" />\n          </motion.svg>\n        )}\n      </AnimatePresence>\n    </span>\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}