AI Patterns

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.

Fix flaky checkout testElapsed 0:00

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.