shadimage
scrimui
uiagent-plansource: ok · hit

Agent Plan

The checklist an agent writes for itself and then edits mid-run — steps added, skipped and re-ordered, without the list jumping under the reader.

npx shadcn add "https://scrimui.dev/r/agent-plan.json"
free

Where it goes

onboarding

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light

Registry dependencies

badgecard

Files1 · source cached 10/8/2026, 3:37:50 PM

src/showcase/agent-plan/agent-plan.tsx
"use client";

import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import * as React from "react";

/**
 * The checklist an agent writes for itself, and then edits while you watch.
 *
 * A static list of steps is a five-minute component. What makes this one
 * worth its own file is that **the plan changes mid-run**, and every naive
 * rendering of that change is worse than not showing the plan at all:
 *
 * **Completed steps must never move.** The model re-emits its plan and the
 * new list is not the old list — a step was inserted at position two, another
 * was dropped. Rendering the new array wholesale slides everything the reader
 * has already read to a new position, and they lose their place in a list
 * they were using precisely to keep it. So finished steps hold their order,
 * and new work arrives underneath.
 *
 * **A dropped step is not a deleted step.** Something the agent said it would
 * do and then decided not to is the most interesting line in the plan. It
 * becomes `skipped`, with the reason, rather than vanishing — a plan that
 * silently loses items is a plan you cannot audit afterwards.
 *
 * **A newly added step should announce itself.** `added` marks work that was
 * not in the original plan, because "the agent decided to do three more
 * things" is a fact the reader wants before it finishes doing them.
 *
 * **A revision counter, not a diff.** Full before/after diffing of a plan is
 * a lot of machinery for a reader who mostly wants to know *that* it changed.
 * `revision` says how many times, and the per-step marks say where.
 */

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

export type PlanStep = {
  id: string;
  text: string;
  state: PlanStepState;
  /** Why it was skipped, or what it produced. One line. */
  note?: string;
  /** True for a step the agent added after the first plan. */
  added?: boolean;
};

export type AgentPlanProps = {
  steps: PlanStep[];
  /** 0 = the original plan. Anything higher is shown next to the title. */
  revision?: number;
  /** The plan itself is still being written. */
  planning?: boolean;
  title?: string;
  className?: string;
};

/* ------------------------------------------------------------------ */
/* Step marks                                                          */
/* ------------------------------------------------------------------ */

function Mark({ state }: { state: PlanStepState }) {
  const base = "mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border";
  switch (state) {
    case "done":
      return (
        <span className={`${base} border-emerald-600 bg-emerald-600 text-white dark:border-emerald-500 dark:bg-emerald-500`}>
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round" width="9" height="9">
            <path d="M20 6 9 17l-5-5" />
          </svg>
        </span>
      );
    case "active":
      return (
        <span className={`${base} border-border text-muted-foreground`}>
          <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
        </span>
      );
    case "failed":
      return (
        <span className={`${base} border-red-500 bg-red-500 text-white`}>
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" width="9" height="9">
            <path d="M18 6 6 18M6 6l12 12" />
          </svg>
        </span>
      );
    case "skipped":
      return (
        <span className={`${base} border-border text-muted-foreground`}>
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" width="9" height="9">
            <path d="M5 12h14" />
          </svg>
        </span>
      );
    default:
      return <span className={`${base} border-border`} />;
  }
}

/* ------------------------------------------------------------------ */
/* AgentPlan                                                           */
/* ------------------------------------------------------------------ */

export function AgentPlan({
  steps,
  revision = 0,
  planning = false,
  title = "Plan",
  className = "",
}: AgentPlanProps) {
  const done = steps.filter((s) => s.state === "done").length;
  const settled = steps.filter((s) => s.state !== "pending" && s.state !== "active").length;

  return (
    <Card className={`gap-0 py-0 rounded-xl border border-border bg-card ${className}`}>
      <div className="flex flex-wrap items-baseline gap-x-2 gap-y-1 border-b border-border px-3.5 py-2.5">
        <span className="text-sm font-medium text-foreground">{title}</span>
        <span className="tabular-nums text-xs text-muted-foreground">
          {done}/{steps.length} done
        </span>
        {revision > 0 && (
          /* The reader's mental model of "the plan" is now out of date, and
             saying so costs one word. */
          <Badge variant="secondary" className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
            revised {revision}×
          </Badge>
        )}
        {planning && (
          <span className="ml-auto inline-flex items-center gap-1.5 text-xs text-muted-foreground">
            <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
            still planning
          </span>
        )}
      </div>

      <ol className="px-3.5 py-3">
        {steps.map((step, i) => (
          <li key={step.id} className="relative flex gap-2.5 pb-3 last:pb-0">
            {/* Rail, drawn between marks rather than behind them, so a
                skipped step reads as part of the sequence it was cut from. */}
            {i < steps.length - 1 && (
              <span className="absolute left-2 top-5 h-[calc(100%-1.25rem)] w-px bg-muted" aria-hidden />
            )}
            <Mark state={step.state} />
            <div className="min-w-0 flex-1">
              <div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
                <span
                  className={`text-sm leading-5 ${
                    step.state === "skipped"
                      ? "text-muted-foreground line-through decoration-border"
                      : step.state === "done"
                        ? "text-muted-foreground"
                        : "text-foreground"
                  }`}
                >
                  {step.text}
                </span>
                {step.added && (
                  <span className="shrink-0 rounded-md bg-muted px-1.5 py-px text-xs font-medium text-foreground">
                    added
                  </span>
                )}
              </div>
              {step.note && (
                <p className="mt-0.5 text-xs leading-4 text-muted-foreground">
                  {step.note}
                </p>
              )}
            </div>
          </li>
        ))}
      </ol>

      {settled > 0 && settled === steps.length && (
        <p className="border-t border-border px-3.5 py-2 text-xs text-muted-foreground">
          Plan complete — {done} done, {steps.length - done} skipped or failed.
        </p>
      )}
    </Card>
  );
}

More from scrimui

Browse all components in this library →