# Agent Plan

> An agent's steps on a glass tube that fills to the one running. Done, failed and skipped steps, substeps, findings.

Category: AI. Docs: https://neonkit.heyaadi.com/components/agent-plan

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/agent-plan.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/agent-plan`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

Builds on: [Icons](https://neonkit.heyaadi.com/components/icons.md). The CLI installs them too.

## Usage

```tsx
import { AgentPlan } from "@/components/ui/agent-plan"

<AgentPlan
  title="Fix the failing date test"
  steps={[
    { title: "Reproduce with TZ=Europe/Berlin", status: "done", meta: "1 failed" },
    { title: "Step by calendar day in src/date.ts", status: "active" },
    { title: "Re-run the suite" },
  ]}
/>
```

## Examples

### A plan in progress

The tube between steps fills down to the step that's running, then that node lights. Substeps tick off under it. When the last step finishes the tube powers down to a steady line.

```tsx
"use client";

import * as React from "react";
import { AgentPlan, type PlanStep, type PlanStepStatus } from "@/components/ui/agent-plan";

type Frame = { at: number; statuses: PlanStepStatus[]; subs: PlanStepStatus[] };

// Each frame is where the plan stands at a moment: step statuses, then the patch step's substeps.
const FRAMES: Frame[] = [
  { at: 0, statuses: ["active", "pending", "pending", "pending"], subs: ["pending", "pending"] },
  { at: 2200, statuses: ["done", "active", "pending", "pending"], subs: ["pending", "pending"] },
  { at: 4600, statuses: ["done", "done", "active", "pending"], subs: ["active", "pending"] },
  { at: 6000, statuses: ["done", "done", "active", "pending"], subs: ["done", "active"] },
  { at: 7400, statuses: ["done", "done", "done", "active"], subs: ["done", "done"] },
  { at: 9400, statuses: ["done", "done", "done", "done"], subs: ["done", "done"] },
];

function stepsFor(frame: Frame): PlanStep[] {
  const [reproduce, cause, patch, rerun] = frame.statuses;
  return [
    {
      id: "reproduce",
      title: "Reproduce with TZ=Europe/Berlin",
      status: reproduce,
      meta: reproduce === "done" ? "1 failed" : undefined,
    },
    {
      id: "cause",
      title: "Find why addDays is an hour short",
      status: cause,
      description:
        cause === "done" ? "It adds 86,400,000 ms per day. October 25 is 25 hours long in Berlin." : undefined,
    },
    {
      id: "patch",
      title: "Step by calendar day in src/date.ts",
      status: patch,
      meta: patch === "done" ? "+3 −1" : undefined,
      substeps: [
        { title: "Replace the millisecond math with setDate", status: frame.subs[0] },
        { title: "Keep the addDays(date, days) signature", status: frame.subs[1] },
      ],
    },
    {
      id: "rerun",
      title: "Re-run the suite",
      status: rerun,
      meta: rerun === "done" ? "14 passed" : undefined,
    },
  ];
}

export default function AgentPlanDemo() {
  const [frame, setFrame] = React.useState(0);
  const [run, setRun] = React.useState(0);

  React.useEffect(() => {
    const timers = FRAMES.slice(1).map((next, index) => setTimeout(() => setFrame(index + 1), next.at + 600));
    return () => timers.forEach(clearTimeout);
  }, [run]);

  const finished = frame === FRAMES.length - 1;

  return (
    <div className="grid w-full max-w-md gap-5">
      <AgentPlan title="Fix the failing date test" steps={stepsFor(FRAMES[frame])} />
      <button
        type="button"
        onClick={() => {
          setFrame(0);
          setRun((n) => n + 1);
        }}
        tabIndex={finished ? 0 : -1}
        className={`w-fit cursor-pointer rounded-md text-[13px] text-white/55 underline decoration-white/20 underline-offset-4 outline-none transition-[color,opacity] hover:text-white/85 focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] ${finished ? "opacity-100" : "pointer-events-none opacity-0"}`}
      >
        Run it again
      </button>
    </div>
  );
}
```

### Stopped by a failure

The light stops at the failed step, which says why. Steps after it are skipped, not pending.

```tsx
import { AgentPlan } from "@/components/ui/agent-plan";

export default function AgentPlanFailed() {
  return (
    <AgentPlan
      className="w-full max-w-md"
      title="Ship ledger 2.14.1 to staging"
      readout="Stopped at 3 of 5"
      steps={[
        { title: "Build the image", status: "done", meta: "48s" },
        { title: "Run the test suite", status: "done", meta: "212 passed" },
        {
          title: "Apply migrations",
          status: "failed",
          meta: "exit 1",
          description: "20261013_invoice_totals: column “total_cents” already exists.",
        },
        { title: "Swap traffic to the new release", status: "skipped" },
        { title: "Post to #releases", status: "skipped" },
      ]}
    />
  );
}
```

### With findings

Descriptions carry what a step found; meta carries the number. The active step shows a live timer when it has no meta.

```tsx
import { AgentPlan } from "@/components/ui/agent-plan";

export default function AgentPlanResearch() {
  return (
    <AgentPlan
      className="w-full max-w-md"
      title="Why did Team churn triple in September?"
      steps={[
        {
          title: "Count cancellations by plan",
          status: "done",
          meta: "637 rows",
          description: "Team 412, Starter 188, Business 37. Only Team moved.",
        },
        {
          title: "Line it up with incidents",
          status: "done",
          substeps: [
            { title: "Status page, September", status: "done", meta: "3 incidents" },
            { title: "Release notes, 2.11 to 2.13", status: "done", meta: "41 entries" },
          ],
        },
        {
          title: "Split Team cancellations by failed invites",
          status: "active",
          substeps: [
            { title: "Join invites to workspaces", status: "done", meta: "18,904" },
            { title: "Flag a failed invite in the 14 days before canceling", status: "active" },
          ],
        },
        { title: "Write up the finding with a chart", status: "pending" },
      ]}
    />
  );
}
```

## API reference

### AgentPlan

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `steps` | `PlanStep[]` | — | { id?, title, label?, description?, status?, meta?, substeps?, content? } |
| `title` | `ReactNode` | — | Heading above the steps. Also names the list. |
| `readout` | `ReactNode \| null` | `"2 of 4"` | Replaces the count of done steps. null hides it. |
| `timer` | `boolean` | `true` | Live timer on the active step when it has no meta. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Failed steps are always red. |

### PlanStep

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `title` | `ReactNode` | — | What the step does. |
| `status` | `"pending" \| "active" \| "done" \| "failed" \| "skipped"` | `"pending"` | The tube lights up to the furthest step that started. |
| `description` | `ReactNode` | — | A line under the title: what it found, why it failed. |
| `meta` | `ReactNode` | — | Right-aligned value. |
| `substeps` | `{ title, status?, meta? }[]` | — | A short checklist under the step. |
| `content` | `ReactNode` | — | Anything else under the step, such as the tool calls it made. |
| `label` | `string` | — | Plain text for announcements when title isn't a string. |

## Accessibility

- An ordered list; the running step has aria-current="step" and every step and substep starts with its status in words.
- A polite live region announces Step 3 of 4 and the step's name when the active step changes, Plan complete at the end, and where it stopped on a failure. Nothing is announced on mount.
- Under prefers-reduced-motion the tube fills instantly and the active node holds still.

## Source

`components/ui/agent-plan.tsx`

```tsx
"use client";

import * as React from "react";
import { Check, X } from "@/components/ui/icons";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

const css = `
@keyframes nk-plan-breathe{0%,100%{opacity:1}50%{opacity:.45}}
.nk-plan-breathe{animation:nk-plan-breathe 2.6s cubic-bezier(0.45,0,0.55,1) infinite}
@media (prefers-reduced-motion:reduce){.nk-plan-breathe{animation:none}}
`;

export type PlanStepStatus = "pending" | "active" | "done" | "failed" | "skipped";

export interface PlanSubstep {
  title: React.ReactNode;
  status?: PlanStepStatus;
  /** Right-aligned value, e.g. "3 files". */
  meta?: React.ReactNode;
}

export interface PlanStep {
  /** Stable key. Falls back to the index. */
  id?: string;
  title: React.ReactNode;
  /** Plain text for announcements when `title` isn't a string. */
  label?: string;
  /** A line under the title: what was found, why it failed. */
  description?: React.ReactNode;
  status?: PlanStepStatus;
  /** Right-aligned value, e.g. "0.8s" or "14 passed". The active step shows a live timer when omitted. */
  meta?: React.ReactNode;
  substeps?: PlanSubstep[];
  /** Anything else under the step: a tool call, a diff. */
  content?: React.ReactNode;
}

const statusWords: Record<PlanStepStatus, string> = {
  pending: "Not started",
  active: "In progress",
  done: "Done",
  failed: "Failed",
  skipped: "Skipped",
};

const RED = "255 77 94";

function useElapsed(running: boolean) {
  const [elapsed, setElapsed] = React.useState<number | null>(null);
  React.useEffect(() => {
    if (!running) return;
    const start = performance.now();
    const tick = () => setElapsed((performance.now() - start) / 1000);
    tick();
    const id = window.setInterval(tick, 100);
    return () => window.clearInterval(id);
  }, [running]);
  return elapsed;
}

function formatElapsed(seconds: number) {
  if (seconds < 10) return `${seconds.toFixed(1)}s`;
  if (seconds < 60) return `${Math.floor(seconds)}s`;
  return `${Math.floor(seconds / 60)}m ${String(Math.floor(seconds % 60)).padStart(2, "0")}s`;
}

function textOf(step: PlanStep) {
  return step.label ?? (typeof step.title === "string" ? step.title : "");
}

/* ------------------------------------------------------------------------------------------------
 * Nodes
 * ---------------------------------------------------------------------------------------------- */

function Node({ status }: { status: PlanStepStatus }) {
  return (
    <span
      aria-hidden
      data-slot="node"
      style={status === "failed" ? ({ "--neon": RED } as React.CSSProperties) : undefined}
      className={cn(
        "relative grid size-4 place-items-center rounded-full border transition-[border-color,background-color] duration-300",
        status === "pending" && "border-white/[0.16]",
        status === "skipped" && "border-white/[0.12]",
        (status === "done" || status === "failed") && "border-[rgb(var(--neon)/0.5)] bg-[rgb(var(--neon)/0.12)]",
        status === "active" && "border-[rgb(var(--neon)/0.85)] [transition-delay:420ms]",
      )}
    >
      {status === "done" && (
        <Check className="size-2.5 text-[color-mix(in_srgb,rgb(var(--neon))_70%,white)]" strokeWidth={3} />
      )}
      {status === "failed" && <X className="size-2.5 text-[rgb(255_120_134)]" strokeWidth={3} />}
      {status === "skipped" && <span className="h-[1.5px] w-1.5 rounded-full bg-white/35" />}
      {/* The lit core arrives a beat after the tube reaches it. */}
      <span
        className={cn(
          "absolute inset-0 grid place-items-center rounded-full transition-opacity",
          status === "active" ? "opacity-100 duration-300 [transition-delay:420ms]" : "opacity-0 duration-150",
        )}
      >
        <span className="nk-plan-breathe absolute inset-[-1px] rounded-full shadow-[0_0_10px_rgb(var(--neon)/CALC(0.6*var(--neon-glow,1))),0_0_22px_rgb(var(--neon)/CALC(0.25*var(--neon-glow,1))),inset_0_0_6px_rgb(var(--neon)/CALC(0.35*var(--neon-glow,1)))]" />
        <span className="size-1.5 rounded-full [background:color-mix(in_srgb,rgb(var(--neon))_50%,white)] shadow-[0_0_3px_rgb(var(--neon)/var(--neon-glow,1)),0_0_8px_rgb(var(--neon)/CALC(0.8*var(--neon-glow,1)))]" />
      </span>
    </span>
  );
}

function SubNode({ status }: { status: PlanStepStatus }) {
  return (
    <span aria-hidden className="grid h-5 w-2.5 shrink-0 place-items-center">
      <span
        className={cn(
          "size-1.5 rounded-full transition-[background-color,box-shadow] duration-300",
          status === "pending" && "shadow-[inset_0_0_0_1px_rgb(255_255_255/0.22)]",
          status === "skipped" && "h-px w-1.5 bg-white/30",
          status === "done" && "bg-[rgb(var(--neon)/0.55)]",
          status === "failed" && "bg-[rgb(255_77_94)]",
          status === "active" &&
            "[background:color-mix(in_srgb,rgb(var(--neon))_50%,white)] shadow-[0_0_3px_rgb(var(--neon)/var(--neon-glow,1)),0_0_8px_rgb(var(--neon)/CALC(0.7*var(--neon-glow,1)))]",
        )}
      />
    </span>
  );
}

function LiveMeta() {
  const elapsed = useElapsed(true);
  return (
    <span
      aria-hidden
      className="font-mono text-xs tabular-nums text-[color-mix(in_srgb,rgb(var(--neon))_50%,white)] [text-shadow:0_0_10px_rgb(var(--neon)/CALC(0.4*var(--neon-glow,1)))]"
    >
      {elapsed === null ? "" : formatElapsed(elapsed)}
    </span>
  );
}

/* ------------------------------------------------------------------------------------------------
 * Plan
 * ---------------------------------------------------------------------------------------------- */

export interface AgentPlanProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "color" | "title"> {
  steps: PlanStep[];
  /** Heading above the steps. Also names the list. */
  title?: React.ReactNode;
  /** Replaces the "2 of 4" count next to the title. Pass null to hide it. */
  readout?: React.ReactNode;
  /** Live timer on the active step when it has no meta. */
  timer?: boolean;
  color?: NeonColor;
}

const AgentPlan = React.forwardRef<HTMLDivElement, AgentPlanProps>(
  ({ steps, title, readout, timer = true, className, style, color, ...props }, ref) => {
    const titleId = React.useId();
    const statuses = steps.map((step) => step.status ?? "pending");
    const done = statuses.filter((status) => status === "done").length;
    const activeIndex = statuses.indexOf("active");
    const failedIndex = statuses.indexOf("failed");
    // Where the light has reached: the furthest step that started.
    const reached = statuses.reduce((last, status, index) => (status === "active" || status === "done" || status === "failed" ? index : last), -1);

    const summary =
      failedIndex >= 0 && activeIndex < 0
        ? `Stopped at step ${failedIndex + 1}: ${textOf(steps[failedIndex])}`
        : activeIndex >= 0
          ? `Step ${activeIndex + 1} of ${steps.length}: ${textOf(steps[activeIndex])}`
          : steps.length > 0 && done === steps.length
            ? "Plan complete"
            : "";
    const [announcement, setAnnouncement] = React.useState("");
    const [lastSummary, setLastSummary] = React.useState(summary);
    if (lastSummary !== summary) {
      setLastSummary(summary);
      setAnnouncement(summary);
    }

    const idle = activeIndex < 0;
    const count = readout === undefined ? `${done} of ${steps.length}` : readout;

    return (
      <div
        ref={ref}
        style={neonVars(color, style)}
        className={cn("[--neon:var(--neon-accent,34_211_238)]", "min-w-0", className)}
        {...props}
      >
        <style href="nk-agent-plan" precedence="nk">
          {css}
        </style>
        {(title || count !== null) && (
          <div className="mb-3 flex min-w-0 items-baseline justify-between gap-4">
            {title && (
              <span id={titleId} className="min-w-0 truncate text-sm font-medium text-white/90">
                {title}
              </span>
            )}
            {count !== null && (
              <span className="ml-auto shrink-0 text-[13px] tabular-nums text-white/50">{count}</span>
            )}
          </div>
        )}
        <ol aria-labelledby={title ? titleId : undefined} aria-label={title ? undefined : "Plan"} className="grid min-w-0">
          {steps.map((step, index) => {
            const status = statuses[index];
            const last = index === steps.length - 1;
            const lit = index < reached;
            const meta = step.meta ?? (status === "active" && timer ? <LiveMeta key={step.id ?? index} /> : null);
            return (
              <li
                key={step.id ?? index}
                aria-current={status === "active" ? "step" : undefined}
                data-status={status}
                className={cn("relative grid min-w-0 grid-cols-[16px_minmax(0,1fr)] gap-x-3", !last && "pb-4")}
              >
                {!last && (
                  // A glass tube between this node and the next, with a blackout gap at both ends.
                  <span
                    aria-hidden
                    data-slot="tube"
                    className="absolute bottom-[-1px] left-[6.5px] top-[23px] w-[3px] rounded-full bg-white/[0.06]"
                  >
                    <span
                      data-lit={lit || undefined}
                      className={cn(
                        "absolute inset-0 origin-top rounded-full",
                        "transition-[transform,opacity,box-shadow] motion-reduce:transition-none",
                        "[background:color-mix(in_srgb,rgb(var(--neon))_80%,white)]",
                        // With nothing running the tube powers down to a steady, unhaloed line.
                        idle
                          ? "opacity-45 shadow-[0_0_0_rgb(var(--neon)/0)]"
                          : "shadow-[0_0_4px_rgb(var(--neon)/CALC(0.7*var(--neon-glow,1))),0_0_12px_rgb(var(--neon)/CALC(0.3*var(--neon-glow,1)))]",
                        lit ? "scale-y-100 ease-[cubic-bezier(0.65,0,0.35,1)]" : "scale-y-0 ease-out",
                        idle ? "duration-[1200ms]" : lit ? "duration-[420ms]" : "duration-200",
                      )}
                    />
                  </span>
                )}
                <span className="flex h-6 items-center">
                  <Node status={status} />
                </span>
                <div className="min-w-0">
                  <div className="flex min-h-6 min-w-0 items-baseline justify-between gap-3">
                    <span
                      className={cn(
                        "min-w-0 text-[14.5px] leading-6 transition-colors duration-300",
                        status === "active" && "font-medium text-white",
                        status === "done" && "text-white/65",
                        status === "pending" && "text-white/70",
                        status === "failed" && "font-medium text-white/90",
                        status === "skipped" && "text-white/40",
                      )}
                    >
                      <span className="sr-only">{statusWords[status]}: </span>
                      {step.title}
                    </span>
                    {(meta || status === "skipped") && (
                      <span
                        className={cn(
                          "shrink-0 text-xs tabular-nums",
                          status === "failed" ? "text-[rgb(255_120_134)]" : "text-white/45",
                        )}
                      >
                        {meta ?? "Skipped"}
                      </span>
                    )}
                  </div>
                  {step.description && (
                    <div
                      className={cn(
                        "mt-0.5 text-[13px] leading-5",
                        status === "failed" ? "text-[rgb(255_150_160)]" : "text-white/50",
                      )}
                    >
                      {step.description}
                    </div>
                  )}
                  {step.substeps && step.substeps.length > 0 && (
                    <ul className="mt-1.5 grid gap-0.5">
                      {step.substeps.map((sub, k) => {
                        const subStatus = sub.status ?? "pending";
                        return (
                          <li key={k} className="flex min-w-0 items-start gap-2.5 text-[13px] leading-5">
                            <SubNode status={subStatus} />
                            <span
                              className={cn(
                                "min-w-0 flex-1",
                                subStatus === "active"
                                  ? "text-white/90"
                                  : subStatus === "pending"
                                    ? "text-white/55"
                                    : subStatus === "skipped"
                                      ? "text-white/40"
                                      : subStatus === "failed"
                                        ? "text-[rgb(255_150_160)]"
                                        : "text-white/50",
                              )}
                            >
                              <span className="sr-only">{statusWords[subStatus]}: </span>
                              {sub.title}
                            </span>
                            {sub.meta && <span className="shrink-0 text-xs leading-5 tabular-nums text-white/45">{sub.meta}</span>}
                          </li>
                        );
                      })}
                    </ul>
                  )}
                  {step.content && <div className="mt-2.5 min-w-0">{step.content}</div>}
                </div>
              </li>
            );
          })}
        </ol>
        <span className="sr-only" aria-live="polite" aria-atomic="true">
          {announcement}
        </span>
      </div>
    );
  },
);
AgentPlan.displayName = "AgentPlan";

export { AgentPlan };
```
