Skip to content
Seamless UI

Run Timeline

Save

A run's status, a per-step progress strip, and its steps in order on a connected rail, with start times, durations, and errors.

Category
Agent
Version
0.2.0
Frameworks
Next.js, Vite + React
Styling
Tailwind CSS v4

Preview

Run Timeline preview

Update the rate-limit docs

1 of 3 steps done

Running
  1. search_docs, Done

    Found 3 pages

    · 800 ms
  2. edit_files, Running

    Update the limits table

  3. open_pull_request, Queued

Installation

npx @seamlessui/cli add run-timeline

Pre-release@seamlessui/cli is not on npm yet. Install from a local build.

Review the plan first. A dry run lists every file and package without changing anything:

npx @seamlessui/cli add run-timeline --dry-run

Usage

run-timeline-example.tsx
import { RunTimeline } from "@/components/seamless/agent/run-timeline";

export default function RunTimelineExample() {
  return (
    <RunTimeline
      className="w-full max-w-md"
      run={{
        title: "Update the rate-limit docs",
        status: "running",
        steps: [
          { id: "1", name: "search_docs", kind: "tool", status: "succeeded", summary: "Found 3 pages", startedAt: "2026-11-14T10:00:00.600Z", finishedAt: "2026-11-14T10:00:01.400Z" },
          { id: "2", name: "edit_files", kind: "tool", status: "running", summary: "Update the limits table", startedAt: "2026-11-14T10:00:01.600Z" },
          { id: "3", name: "open_pull_request", kind: "tool", status: "queued" },
        ],
      }}
    />
  );
}

States

Every state, rendered from local fixture data.

Run Timeline states

Research run

No steps yet

Queued

Steps appear here as the agent works.

Research run

1 of 3 steps done

Running
  1. search_docs, Done

    Found 3 pages

    · 800 ms
  2. fetch_page, Running

    Read /docs/api/rate-limits

  3. open_pull_request, Queued

Research run

2 of 3 steps done

Needs approval
  1. search_docs, Done

    Found 3 pages

    · 800 ms
  2. fetch_page, Done

    Read /docs/api/rate-limits

    · 1.3 s
  3. open_pull_request, Queued

Research run

3 of 3 steps done · 3.4 s

Succeeded
  1. search_docs, Done

    Found 3 pages

    · 800 ms
  2. fetch_page, Done

    Read /docs/api/rate-limits

    · 1.3 s
  3. open_pull_request, Done

Research run

1 of 3 steps done · 2.3 s

Failed

fetch_page failed: 504 Gateway Timeout.

  1. search_docs, Done

    Found 3 pages

    · 800 ms
  2. fetch_page, Failed

    504 Gateway Timeout

    · 1.3 s
  3. open_pull_request, Skipped

Research run

2 of 3 steps done · 2.5 s

Cancelled
  1. search_docs, Done

    Found 3 pages

    · 800 ms
  2. fetch_page, Done

    Read /docs/api/rate-limits

    · 1.3 s
  3. open_pull_request, Skipped

API

PropTypeDefaultDescription
runPick<AgentRun, "title" | "status" | "steps" | "error" | "createdAt" | "finishedAt">—The run to show. Times are shown in UTC so server and client render the same text.

Accessibility

  • The run status label is a live region, so status changes are announced.
  • Each step includes its status as screen-reader text and as a visible label; the progress strip is decorative.
  • Failures are shown as text, not only red.
  • With reduced motion the spinner holds still and status changes are instant.

Files

Installed into src/components/seamless, or components/seamless when the project has no src directory.

  • src/components/seamless/agent/run-timeline.tsxrun-timeline
  • Also installs badge@0.2.0registry dependency
  • Also installs agent-run@0.1.1registry dependency
  • Also installs step-list@0.2.0registry dependency
  • npm packages: noneReact and Tailwind CSS v4 required

Source

The exact source the CLI installs, from /r/items/react/run-timeline/0.2.0.json.

agent/run-timeline.tsxShow
agent/run-timeline.tsx
import type { ComponentProps } from "react";
import { Badge, type BadgeTone } from "../ui/badge";
import type { AgentRun, RunStatus, StepStatus } from "./agent-run";
import { StatusGlyph, type StatusGlyphKind, StepList } from "./step-list";

const runStatus: Record<RunStatus, { label: string; tone: BadgeTone; glyph: StatusGlyphKind }> = {
  queued: { label: "Queued", tone: "neutral", glyph: "queued" },
  running: { label: "Running", tone: "info", glyph: "running" },
  "waiting-for-approval": { label: "Needs approval", tone: "warning", glyph: "waiting" },
  succeeded: { label: "Succeeded", tone: "success", glyph: "success" },
  failed: { label: "Failed", tone: "danger", glyph: "failed" },
  cancelled: { label: "Cancelled", tone: "neutral", glyph: "skipped" },
};

const segment: Record<StepStatus, string> = {
  queued: "bg-zinc-950/10 dark:bg-white/12",
  running: "bg-sky-500",
  succeeded: "bg-emerald-500",
  failed: "bg-red-500",
  skipped: "bg-zinc-950/[0.05] dark:bg-white/[0.06]",
};

export interface RunTimelineProps extends Omit<ComponentProps<"section">, "children"> {
  run: Pick<AgentRun, "title" | "status" | "steps" | "error" | "createdAt" | "finishedAt">;
}

/** The run's status and its steps in order on a connected rail, with start times and durations. */
export function RunTimeline({ run, className, ...props }: RunTimelineProps) {
  const meta = runStatus[run.status];
  const done = run.steps.filter((step) => step.status === "succeeded").length;
  const total = run.createdAt && run.finishedAt ? formatDuration(run.createdAt, run.finishedAt) : "";

  return (
    <section
      aria-label={`${run.title} timeline`}
      data-status={run.status}
      className={[
        "rounded-xl border bg-white text-zinc-950 shadow-[0_1px_2px_rgb(0_0_0/0.04)] transition-[border-color] duration-200 motion-reduce:transition-none",
        "dark:bg-black dark:text-white dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06)]",
        run.status === "waiting-for-approval"
          ? "border-amber-500/35"
          : run.status === "failed"
            ? "border-red-500/35"
            : "border-zinc-950/10 dark:border-white/10",
        className,
      ]
        .filter(Boolean)
        .join(" ")}
      {...props}
    >
      <header className="flex items-start gap-3 px-4 pt-4">
        <StatusGlyph key={meta.glyph} kind={meta.glyph} size={20} />
        <div className="min-w-0 flex-1">
          <h3 className="truncate text-sm font-semibold">{run.title}</h3>
          <p className="mt-0.5 text-xs text-zinc-500 tabular-nums">
            {run.steps.length ? `${done} of ${run.steps.length} steps done` : "No steps yet"}
            {total ? ` · ${total}` : ""}
          </p>
        </div>
        <span role="status" className="shrink-0">
          <Badge tone={meta.tone}>{meta.label}</Badge>
        </span>
      </header>

      {run.steps.length ? (
        <div aria-hidden="true" className="mx-4 mt-3 flex gap-1">
          {run.steps.map((step) => (
            <span
              key={step.id}
              className={`h-1 flex-1 rounded-full transition-colors duration-200 motion-reduce:transition-none ${segment[step.status]}`}
            />
          ))}
        </div>
      ) : null}

      {run.status === "failed" && run.error ? (
        <p className="mx-4 mt-3 rounded-lg border border-red-500/35 bg-red-500/[0.08] px-3 py-2 text-sm text-red-700 dark:text-red-300">
          {run.error}
        </p>
      ) : null}

      <div className="mt-3 border-t border-zinc-950/[0.07] px-4 py-4 dark:border-white/[0.08]">
        {run.steps.length ? (
          <StepList
            steps={run.steps.map((step) => ({
              id: step.id,
              status: step.status,
              label: step.kind === "tool" ? <span className="font-mono text-[13px]">{step.name}</span> : step.name,
              detail: step.status === "failed" && step.error ? step.error : step.summary,
              meta: step.startedAt ? (
                <>
                  <time dateTime={step.startedAt}>{formatTime(step.startedAt)}</time>
                  {step.finishedAt ? ` · ${formatDuration(step.startedAt, step.finishedAt)}` : ""}
                </>
              ) : undefined,
            }))}
          />
        ) : (
          <p className="text-sm text-zinc-500">Steps appear here as the agent works.</p>
        )}
      </div>
    </section>
  );
}

function formatTime(iso: string): string {
  const date = new Date(iso);
  return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(11, 19);
}

function formatDuration(start: string, end: string): string {
  const ms = new Date(end).getTime() - new Date(start).getTime();
  if (!Number.isFinite(ms) || ms < 0) return "";
  return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`;
}