# Run timeline

The steps of one agent run as a vertical timeline: a connector that fills as steps finish, a live timer on the active step, and a waiting step that holds an approval.

Tier: free. Type: pattern. Category: ai. Tags: agent, run, trace, approval, motion.

## Install

```bash
npx shadcn@latest add @pixilui/run-timeline
```

One-time setup: add the registry to `components.json`:

```json
{
  "registries": {
    "@pixilui": "https://pixilui.com/r/{name}.json"
  }
}
```

Files: `components/pixilui/run-timeline.tsx`. npm: `motion`.

## Preview

Live preview: https://pixilui.com/components/run-timeline

Knobs:

- `state`: `play`, `waiting`, `error`, `done`
- `mode`: `expanded`, `compact`
- `approval`: `auto`, `manual`

Signature: The connector fills down to the next step as each check draws itself, until the run stops on an approval card and the card folds into a receipt.

## Code

`components/pixilui/run-timeline.tsx`

```tsx
"use client";

import { forwardRef, useEffect, useState, useSyncExternalStore, type ComponentPropsWithoutRef, type KeyboardEvent, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "./lib/utils";
import { instantTransition, motionTokens, reducedTransition } from "./motion-tokens";

const noSubscription = () => () => {};

/**
 * useReducedMotion, held at false until hydration finishes. The server cannot know the
 * preference, so reading it on the first client render would change the markup and break
 * hydration; the reduced branch takes over on the next render instead.
 */
function useReducedMotionAfterHydration() {
  const hydrated = useSyncExternalStore(noSubscription, () => true, () => false);
  const reduced = useReducedMotion();
  return hydrated && (reduced ?? false);
}

export type RunTimelineStatus = "pending" | "active" | "done" | "error" | "waiting";

export type RunTimelineStep = {
  /** Stable key for the step; onStepSelect receives it. */
  id: string;
  /** What the step does, in a few words: "Pull stuck refunds from Stripe". */
  title: ReactNode;
  /** Evidence once it ran ("9 refunds pending for more than 72 hours"), or why it failed. Hidden in compact mode. */
  detail?: ReactNode;
  /** Pending, active (live timer), done (drawn check), error, or waiting on a person. */
  status: RunTimelineStatus;
  /** When the step started (epoch ms). Drives the live timer and the duration. */
  startedAt?: number;
  /** When the step finished (epoch ms). With startedAt, shows how long it took. */
  endedAt?: number;
  /** Content under the step: an approval card on a waiting step, a tool call, a diff. Waiting steps show it in compact mode too. */
  children?: ReactNode;
};

export type RunTimelineLabels = Record<RunTimelineStatus, string>;

const defaultLabels: RunTimelineLabels = {
  pending: "Up next",
  active: "In progress",
  done: "Done",
  error: "Failed",
  waiting: "Needs approval",
};

export type RunTimelineProps = Omit<ComponentPropsWithoutRef<"ol">, "children"> & {
  /** The run's steps, in order. Update a step's status and the timeline animates the change. */
  steps: RunTimelineStep[];
  /** Titles only, tighter rhythm. Waiting steps still show their children. */
  compact?: boolean;
  /** Makes each step a button. Called with the step's id. */
  onStepSelect?: (id: string) => void;
  /** The step shown elsewhere (a detail pane), marked with data-selected. */
  selectedId?: string;
  /** Status words, read by screen readers and shown for waiting and failed steps. */
  labels?: Partial<RunTimelineLabels>;
};

const TICK_MS = 250;

/** "820ms", "1.4s", "1m 04s". */
function formatDuration(ms: number) {
  if (ms < 1000) return `${Math.round(ms)}ms`;
  if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
  const s = Math.round(ms / 1000);
  return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
}

function formatLive(ms: number) {
  const s = Math.floor(ms / 1000);
  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
}

/** A live timer whose digits are keyed by place and roll up. Only this re-renders each tick. */
function LiveTimer({ startedAt, reduced }: { startedAt: number; reduced: boolean }) {
  const [now, setNow] = useState<number | null>(null);
  useEffect(() => {
    const tick = () => setNow(Date.now());
    tick();
    const id = setInterval(tick, TICK_MS);
    return () => clearInterval(id);
  }, [startedAt]);
  if (now === null) return null;
  const value = formatLive(Math.max(0, now - startedAt));
  const chars = value.split("");
  return (
    <span role="timer" aria-label={value} className="numeric inline-flex">
      {chars.map((char, index) => {
        const place = chars.length - index;
        if (reduced || !/\d/.test(char)) {
          return (
            <span key={place} aria-hidden="true">
              {char}
            </span>
          );
        }
        return (
          <span key={place} aria-hidden="true" className="relative inline-flex h-[1lh] overflow-hidden">
            <AnimatePresence initial={false} mode="popLayout">
              <motion.span key={char} initial={{ y: "100%" }} animate={{ y: 0 }} exit={{ y: "-100%" }} transition={motionTokens.spring.snappy}>
                {char}
              </motion.span>
            </AnimatePresence>
          </span>
        );
      })}
    </span>
  );
}

/** Glyph rings sit between the 1px hairline and a 2px border. */
const ring = { borderWidth: 1.5 };

function Mark({ status, reduced }: { status: RunTimelineStatus; reduced: boolean }) {
  const draw = reduced
    ? { initial: { pathLength: 1 }, transition: reducedTransition }
    : { initial: { pathLength: 0 }, transition: { duration: motionTokens.duration.slow, ease: motionTokens.ease.enter, delay: motionTokens.stagger.item * 2 } };
  switch (status) {
    case "pending":
      return <span className="size-2.5 rounded-pill border-solid border-border-strong" style={ring} />;
    case "active":
      return (
        <span className="grid size-5 place-items-center rounded-pill border-solid border-accent-solid" style={ring}>
          <span className="size-2 rounded-pill bg-accent-solid" />
        </span>
      );
    case "done":
      return (
        <span className="grid size-5 place-items-center rounded-pill bg-accent-solid text-accent-contrast">
          <svg viewBox="0 0 12 12" fill="none" className="size-3">
            <motion.path d="M2.75 6.25l2.25 2.25 4.25-4.75" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" initial={draw.initial} animate={{ pathLength: 1 }} transition={draw.transition} />
          </svg>
        </span>
      );
    case "error":
      return (
        <span className="grid size-5 place-items-center rounded-pill bg-danger-subtle text-danger">
          <svg viewBox="0 0 12 12" fill="none" className="size-3">
            <motion.path d="M3.75 3.75l4.5 4.5M8.25 3.75l-4.5 4.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" initial={draw.initial} animate={{ pathLength: 1 }} transition={draw.transition} />
          </svg>
        </span>
      );
    case "waiting":
      return (
        <span className="grid size-5 place-items-center rounded-pill bg-warning-subtle text-warning">
          <svg viewBox="0 0 12 12" fill="none" className="size-3">
            <path d="M4.5 3.5v5M7.5 3.5v5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
          </svg>
        </span>
      );
  }
}

/**
 * Detail and children open their own height, so the steps below are pushed along in the flow
 * (no overlap while the content fades in). Growing uses morph, closing uses smooth. The clip is
 * lifted once it has opened, so an approval card's shadow and focus ring show in full.
 */
function Reveal({ reduced, className, children, ...rest }: { reduced: boolean; className?: string; children: ReactNode; "data-slot"?: string }) {
  const [clip, setClip] = useState(true);
  const motionProps = reduced
    ? {
        initial: { opacity: 0 },
        animate: { opacity: 1 },
        exit: { opacity: 0, height: 0, transition: instantTransition },
        transition: reducedTransition,
      }
    : {
        initial: { opacity: 0, height: 0, filter: `blur(${motionTokens.blur.subtle}px)` },
        animate: {
          opacity: 1,
          height: "auto",
          filter: "blur(0px)",
          transition: {
            height: motionTokens.spring.morph,
            default: { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter, delay: motionTokens.stagger.item * 2 },
          },
        },
        exit: {
          opacity: 0,
          height: 0,
          filter: `blur(${motionTokens.blur.subtle}px)`,
          transition: { height: motionTokens.spring.smooth, default: { duration: motionTokens.duration.instant, ease: motionTokens.ease.exit } },
        },
      };
  return (
    <motion.div
      {...rest}
      {...motionProps}
      className={className}
      style={{ overflow: clip && !reduced ? "hidden" : "visible" }}
      onAnimationStart={() => setClip(true)}
      onAnimationComplete={() => setClip(false)}
    >
      {children}
    </motion.div>
  );
}

export const RunTimeline = forwardRef<HTMLOListElement, RunTimelineProps>(function RunTimeline(
  { steps, compact = false, onStepSelect, selectedId, labels: labelsProp, className, onKeyDown, ...rest },
  ref,
) {
  const labels = { ...defaultLabels, ...labelsProp };
  const reduced = useReducedMotionAfterHydration();

  // Up and Down walk the step buttons, Home and End jump to the ends.
  const handleKeyDown = (event: KeyboardEvent<HTMLOListElement>) => {
    onKeyDown?.(event);
    if (event.defaultPrevented || !onStepSelect || !["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
    const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLElement>('[data-slot="run-timeline-trigger"]'));
    const current = buttons.indexOf(event.target as HTMLElement);
    if (current === -1) return;
    event.preventDefault();
    const next =
      event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : Math.min(buttons.length - 1, Math.max(0, current + (event.key === "ArrowDown" ? 1 : -1)));
    buttons[next]?.focus();
  };

  const swapIn = reduced
    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0, transition: instantTransition }, transition: instantTransition }
    : {
        initial: { opacity: 0, scale: motionTokens.scale.enterFrom, filter: `blur(${motionTokens.blur.subtle}px)` },
        animate: { opacity: 1, scale: 1, filter: "blur(0px)" },
        exit: { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: motionTokens.ease.exit } },
        transition: { duration: motionTokens.duration.fast, ease: motionTokens.ease.swap },
      };

  return (
    <ol
      ref={ref}
      data-compact={compact || undefined}
      className={cn("flex w-full min-w-0 flex-col font-body text-14 text-foreground", className)}
      onKeyDown={handleKeyDown}
      {...rest}
    >
      {steps.map((step, index) => {
        const last = index === steps.length - 1;
        const duration = step.startedAt !== undefined && step.endedAt !== undefined ? step.endedAt - step.startedAt : null;
        const showDetail = !compact && step.detail;
        const showChildren = step.children && (!compact || step.status === "waiting");
        const flagged = step.status === "waiting" || step.status === "error";

        const titleRow = (
          <>
            <span className={cn("min-w-0 flex-1 text-pretty font-medium transition-colors duration-fast ease-swap", step.status === "pending" ? "text-muted" : "text-foreground")}>
              {step.title}
            </span>
            <span className="flex shrink-0 items-center gap-2 pt-px text-12 text-muted">
              {flagged ? <span className={step.status === "error" ? "text-danger" : "text-warning"}>{labels[step.status]}</span> : null}
              {step.status === "active" && step.startedAt !== undefined ? <LiveTimer startedAt={step.startedAt} reduced={reduced} /> : null}
              {step.status === "done" && duration !== null ? <span className="numeric">{formatDuration(duration)}</span> : null}
            </span>
          </>
        );

        return (
          <li
            key={step.id}
            data-status={step.status}
            data-selected={selectedId === step.id || undefined}
            aria-current={step.status === "active" ? "step" : undefined}
            className="relative grid min-w-0 grid-cols-[1.25rem_minmax(0,1fr)] gap-x-3"
          >
            {/* Rail: the glyph, then the connector to the next step, which fills when this one is done. */}
            <span aria-hidden="true" className="relative flex justify-center">
              <span className="relative mt-0.5 grid size-5 place-items-center">
                <AnimatePresence initial={false}>
                  <motion.span key={step.status} data-slot="run-timeline-glyph" className="absolute inset-0 grid place-items-center" {...swapIn}>
                    <Mark status={step.status} reduced={reduced} />
                  </motion.span>
                </AnimatePresence>
              </span>
              {last ? null : (
                <span className="absolute top-7 bottom-1 left-1/2 w-0.5 -translate-x-1/2 overflow-hidden rounded-pill bg-border">
                  <motion.span
                    data-slot="run-timeline-connector"
                    className="absolute inset-0 origin-top rounded-pill bg-accent-solid"
                    initial={false}
                    animate={{ scaleY: step.status === "done" ? 1 : 0 }}
                    transition={reduced ? instantTransition : { duration: motionTokens.duration.slow, ease: motionTokens.ease.linear }}
                  />
                </span>
              )}
            </span>

            <div className={cn("min-w-0", last ? "pb-0" : compact ? "pb-3" : "pb-6")}>
              {onStepSelect ? (
                <button
                  type="button"
                  data-slot="run-timeline-trigger"
                  aria-pressed={selectedId !== undefined ? selectedId === step.id : undefined}
                  onClick={() => onStepSelect(step.id)}
                  className={cn(
                    "-mx-2 -my-1 flex w-[calc(100%+1rem)] min-w-0 items-start gap-3 rounded-control px-2 py-1 text-left outline-none transition-colors duration-instant ease-swap hover-fine:bg-surface-muted focus-visible:focus-ring",
                    selectedId === step.id && "bg-surface-muted",
                  )}
                >
                  {titleRow}
                </button>
              ) : (
                <div className="flex min-w-0 items-start gap-3">{titleRow}</div>
              )}
              <span className="sr-only">{labels[step.status]}</span>

              <AnimatePresence initial={false}>
                {showDetail ? (
                  <Reveal key="detail" reduced={reduced}>
                    <div className={cn("pt-0.5 text-pretty", step.status === "error" ? "text-danger" : "text-secondary")}>{step.detail}</div>
                  </Reveal>
                ) : null}
              </AnimatePresence>
              <AnimatePresence initial={false}>
                {showChildren ? (
                  <Reveal key="children" data-slot="run-timeline-children" className="min-w-0" reduced={reduced}>
                    <div className="pt-3">{step.children}</div>
                  </Reveal>
                ) : null}
              </AnimatePresence>
            </div>
          </li>
        );
      })}
    </ol>
  );
});
```

## Usage

When to use:

- Showing an agent run in progress, step by step, with the evidence of what each step did.
- A run that pauses for a person: the waiting step hosts an approval card in place.
- A run history view where each step can open details elsewhere (onStepSelect).

When not to use:

- A flat list of tool calls inside one message: use tool-call-row with ToolCallRowGroup.
- A quiet status line while a model thinks: use thinking-state.
- A form wizard or onboarding progress: use a stepper.

```tsx
import { useEffect, useState, useSyncExternalStore } from "react";
import { useReducedMotion } from "motion/react";
import { Mail, RotateCcw } from "lucide-react";
import { Button } from "@/components/pixilui/button";
import { ApprovalCard, type ApprovalCardItem, type ApprovalCardStatus } from "@/components/pixilui/approval-card";
import { RunTimeline, type RunTimelineStatus, type RunTimelineStep } from "@/components/pixilui/run-timeline";

const noSubscription = () => () => {};

/**
 * useReducedMotion, held at false until hydration finishes. The server cannot know the
 * preference, so reading it on the first client render would change the markup and break
 * hydration; the reduced branch takes over on the next render instead.
 */
function useReducedMotionAfterHydration() {
  const hydrated = useSyncExternalStore(noSubscription, () => true, () => false);
  const reduced = useReducedMotion();
  return hydrated && (reduced ?? false);
}

// The first option of each knob is the demo's default; the docs preview starts there.
export const knobs = {
  state: ["play", "waiting", "error", "done"],
  mode: ["expanded", "compact"],
  approval: ["auto", "manual"],
} as const;

type StepCopy = { id: string; title: string; detail: string };

const STEPS: StepCopy[] = [
  { id: "pull", title: "Pull stuck refunds from Stripe", detail: "9 refunds pending for more than 72 hours" },
  { id: "match", title: "Match refunds to customer accounts", detail: "7 accounts, with 2 duplicate refunds merged" },
  { id: "draft", title: "Draft apology emails with a $15 credit", detail: "7 drafts in the support voice, 94 to 118 words each" },
  { id: "approve", title: "Get approval to send", detail: "Approved by Dana Whitfield" },
  { id: "post", title: "Post the summary to #support-ops", detail: "Posted with links to ENG-4127 and the 7 tickets" },
];

const ITEMS: ApprovalCardItem[] = [
  { label: "Recipients", value: "7 customers" },
  { label: "Credits", value: "$105.00", numeric: true },
  { label: "Sends from", value: "support@northwind.co" },
];

type StepState = { status: RunTimelineStatus; startedAt?: number; endedAt?: number };
type Frame = { steps: StepState[]; approval: ApprovalCardStatus };

// Fixed clock for the still states, so durations read the same on every render.
const T0 = 1_761_400_000_000;
const DURATIONS = [1140, 870, 2260, 4120, 930];
const ended = (index: number): StepState => {
  const start = T0 + DURATIONS.slice(0, index).reduce((a, b) => a + b, 0);
  return { status: "done", startedAt: start, endedAt: start + DURATIONS[index]! };
};

const STILL: Record<"waiting" | "error" | "done", Frame> = {
  waiting: { steps: [ended(0), ended(1), ended(2), { status: "waiting" }, { status: "pending" }], approval: "pending" },
  error: { steps: [ended(0), ended(1), { status: "error" }, { status: "pending" }, { status: "pending" }], approval: "pending" },
  done: { steps: STEPS.map((_, index) => ended(index)), approval: "approved" },
};

const START: Frame = { steps: STEPS.map(() => ({ status: "pending" })), approval: "pending" };
const STEP_MS = 900;

export default function RunTimelineDemo({ state = "play", mode = "expanded", approval = "auto" }: KnobValues<typeof knobs>) {
  const reduced = useReducedMotionAfterHydration();
  const [run, setRun] = useState(0);
  const [frame, setFrame] = useState<Frame>(START);
  const [finished, setFinished] = useState(false);

  const playing = state === "play" && !reduced;

  const combo = `${state}-${approval}-${reduced}`;
  const [seen, setSeen] = useState<string | null>(null);
  if (seen !== combo) {
    setSeen(combo);
    setFinished(false);
    setFrame(playing ? START : STILL[state === "play" ? "done" : state]);
  }

  const setStep = (index: number, next: StepState) => setFrame((f) => ({ ...f, steps: f.steps.map((s, i) => (i === index ? next : s)) }));

  // Steps 1 to 3 run at reading speed, then the run waits on a person.
  useEffect(() => {
    if (!playing) return;
    const begin = (index: number) => () => setFrame((f) => ({ ...f, steps: f.steps.map((s, i) => (i === index ? { status: "active", startedAt: Date.now() } : s)) }));
    const finish = (index: number) => () =>
      setFrame((f) => ({ ...f, steps: f.steps.map((s, i) => (i === index ? { ...s, status: "done", endedAt: Date.now() } : s)) }));
    const plan: [number, () => void][] = [
      [300, begin(0)],
      [300 + STEP_MS, finish(0)],
      [300 + STEP_MS, begin(1)],
      [300 + STEP_MS * 2, finish(1)],
      [300 + STEP_MS * 2, begin(2)],
      [300 + STEP_MS * 3, finish(2)],
      [300 + STEP_MS * 3, () => setFrame((f) => ({ ...f, steps: f.steps.map((s, i) => (i === 3 ? { status: "waiting", startedAt: Date.now() } : s)) }))],
    ];
    if (approval === "auto") plan.push([300 + STEP_MS * 3 + 2000, () => setFrame((f) => ({ ...f, approval: "approved" }))]);
    const timers = plan.map(([at, fn]) => setTimeout(fn, at));
    return () => timers.forEach(clearTimeout);
  }, [playing, approval, run]);

  // Once approved: close the waiting step, then run the last one.
  const approved = frame.approval === "approved";
  const waitingOnApproval = frame.steps[3]!.status === "waiting";
  const lastStatus = frame.steps[4]!.status;
  useEffect(() => {
    if (!approved || !waitingOnApproval) return;
    const id = setTimeout(
      () => setFrame((f) => ({ ...f, steps: f.steps.map((s, i) => (i === 3 ? { ...s, status: "done", endedAt: Date.now() } : i === 4 ? { status: "active", startedAt: Date.now() } : s)) })),
      500,
    );
    return () => clearTimeout(id);
  }, [approved, waitingOnApproval]);
  useEffect(() => {
    if (lastStatus === "active") {
      const id = setTimeout(() => setFrame((f) => ({ ...f, steps: f.steps.map((s, i) => (i === 4 ? { ...s, status: "done", endedAt: Date.now() } : s)) })), STEP_MS + 100);
      return () => clearTimeout(id);
    }
    if (lastStatus === "done") {
      const id = setTimeout(() => setFinished(true), STEP_MS);
      return () => clearTimeout(id);
    }
  }, [lastStatus]);

  const steps: RunTimelineStep[] = STEPS.map((copy, index) => {
    const s = frame.steps[index]!;
    const detail =
      s.status === "done" ? copy.detail : s.status === "error" ? "Template refund-apology-v2 was archived on Oct 21. Pick another and retry." : undefined;
    const card =
      copy.id === "approve" && (s.status === "waiting" || s.status === "done") ? (
        <ApprovalCard
          title="Send 7 apology emails and apply $105.00 in credits"
          icon={<Mail strokeWidth={1.5} />}
          source="Support agent, run 7f2e1c"
          items={ITEMS}
          risk="medium"
          status={frame.approval}
          receipt="7 emails queued, $105.00 in credits applied"
          labels={{ approve: "Approve and send" }}
          onApprove={async () => {
            await new Promise((resolve) => setTimeout(resolve, 700));
            setFrame((f) => ({ ...f, approval: "approved" }));
          }}
          onReject={async () => {
            await new Promise((resolve) => setTimeout(resolve, 500));
            setStep(3, { status: "error" });
          }}
        />
      ) : undefined;
    return { id: copy.id, title: copy.title, detail: copy.id === "approve" ? undefined : detail, status: s.status, startedAt: s.startedAt, endedAt: s.endedAt, children: card };
  });

  return (
    <div className="flex w-full min-w-0 max-w-lg flex-col gap-4">
      <div className="min-h-[30rem]">
        <RunTimeline aria-label="Refund run 7f2e1c" steps={steps} compact={mode === "compact"} />
      </div>
      <div className="flex h-control-sm justify-center">
        {state === "play" && !reduced && finished ? (
          <Button
            variant="ghost"
            size="sm"
            icon={<RotateCcw strokeWidth={1.5} />}
            onClick={() => {
              setFrame(START);
              setFinished(false);
              setRun((n) => n + 1);
            }}
          >
            Replay
          </Button>
        ) : null}
      </div>
    </div>
  );
}
```

## Examples

None.

## API

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `steps` (required) | `RunTimelineStep[]` |  | The run's steps, in order. Update a step's status and the timeline animates the change. |
| `compact` | `boolean` | `false` | Titles only, tighter rhythm. Waiting steps still show their children. |
| `onStepSelect` | `(id: string) => void` |  | Makes each step a button. Called with the step's id. |
| `selectedId` | `string` |  | The step shown elsewhere (a detail pane), marked with data-selected. |
| `labels` | `Partial<RunTimelineLabels>` |  | Status words, read by screen readers and shown for waiting and failed steps. |

Also accepts every native `<ol>` attribute, and a ref to it.

## Keyboard

- `Tab`: Moves through step buttons when onStepSelect is set, and into a waiting step's approval.
- `Enter`: Selects the focused step.
- `ArrowDown` + `ArrowUp`: Moves between step buttons when onStepSelect is set.
- `Home` + `End`: Jumps to the first or last step button.

## Accessibility

- An ordered list, so screen readers say how many steps there are and where each sits.
- The active step has aria-current="step"; every step carries its status in text (Done, In progress, Up next, Failed, Needs approval).
- Waiting and failed steps also show their status as visible words, not only as a colour or a glyph.
- The live timer has role timer, so it is not announced every second.
- With onStepSelect, steps are real buttons; selectedId sets aria-pressed.

## Motion

- Status changes crossfade the glyph through a 2px blur in 250ms on the swap easing; the done check draws itself.
- The connector under a finished step fills top to bottom in 400ms on linear easing, so progress reads segment by segment.
- Detail and children open their own height on the morph spring and fade up, pushing the steps below instead of overlapping them.
- The active step's timer digits roll up on the snappy spring.
- Reduced motion: glyphs and connectors swap instantly, content fades in.

## Responsive

- Fills its container; titles and details wrap, timings stay on the title line.
- Compact mode drops details and tightens the rhythm for side panels; waiting steps keep their approval.

## Performance

- Only the active step's timer re-renders each tick; the list re-renders when steps change.
- Connectors animate transform only.

## Notes for AI

- Keep one source of truth: map your run events to steps with id, title, status, startedAt and endedAt, and let the component animate the changes.
- Write detail as evidence in past tense once a step is done ("9 refunds pending for more than 72 hours"); on error, say what failed and what to do.
- Put an approval-card in the children of the waiting step; it stays visible in compact mode. Keep it there after approval so its receipt shows what was approved.
- Use onStepSelect with selectedId to open step details in a side pane.
- Statuses are pending, active, done, error and waiting; keep at most one active.

## Related

- [Approval card](https://pixilui.com/components/approval-card/markdown): An agent asks before it acts: what it will do, the rows that matter, the risk, and approve or reject with a note.
- [Tool call row](https://pixilui.com/components/tool-call-row/markdown): One line per tool call: what it did in plain words, the tool's name, a live timer, and a fold-out with the input and output as copyable JSON. ToolCallRowGroup sums several calls into one line.
- [Thinking state](https://pixilui.com/components/thinking-state/markdown): A quiet line that says what the agent is doing while it works, with a live timer, steps that unfold, and a fold into "Thought for 12s" when it is done.
- [Streaming text](https://pixilui.com/components/streaming-text/markdown): Renders a model's answer as it streams: only new words rise into place at reading speed, a caret holds the end, and half-arrived words wait until they are whole.
