AI Patterns

Plan ReviewNew

An editable plan shown before an agent runs — reword, reorder, remove or add steps, or ask for a new plan — that locks once approved.

JobOversee
Seen in

Here's my plan

Nothing runs until you approve. Estimated ~4 min.

Enter to edit, Alt plus arrow keys to move, Delete to remove.
"use client";

import * as React from "react";
import { AnimatePresence, Reorder, useDragControls } from "motion/react";
import { Check, GripVertical, Plus, RotateCcw, X } from "lucide-react";

import { cn } from "@/lib/utils";

export interface PlanStep {
  id: string;
  text: string;
}

export interface PlanReviewProps {
  title?: string;
  steps: PlanStep[];
  /** Rough cost or duration of running the plan ("~6 min · 40 credits"). */
  estimate?: string;
  onApprove: (steps: PlanStep[]) => void;
  /** Asks the agent for a fresh plan. */
  onRegenerate?: () => Promise<PlanStep[]>;
  approveLabel?: string;
  className?: string;
}

type Phase = "review" | "regenerating" | "approved";

export function PlanReview({
  title = "Here's my plan",
  steps: initialSteps,
  estimate,
  onApprove,
  onRegenerate,
  approveLabel = "Approve & run",
  className,
}: PlanReviewProps) {
  const [steps, setSteps] = React.useState(initialSteps);
  // What "Edited" compares against: the agent's latest plan.
  const [baseline, setBaseline] = React.useState(initialSteps);
  const [phase, setPhase] = React.useState<Phase>("review");
  const [editingId, setEditingId] = React.useState<string | null>(null);
  const [announcement, setAnnouncement] = React.useState("");
  const counter = React.useRef(0);
  const listRef = React.useRef<HTMLUListElement>(null);
  const keysId = React.useId();

  const changed = JSON.stringify(steps) !== JSON.stringify(baseline);
  const locked = phase !== "review";

  function update(id: string, text: string) {
    setSteps((s) => s.map((step) => (step.id === id ? { ...step, text } : step)));
  }

  function remove(id: string) {
    setSteps((s) => s.filter((step) => step.id !== id));
    setAnnouncement("Step removed");
  }

  function add() {
    const id = `new-${++counter.current}`;
    setSteps((s) => [...s, { id, text: "" }]);
    setEditingId(id);
  }

  function move(id: string, by: -1 | 1) {
    const i = steps.findIndex((step) => step.id === id);
    const j = i + by;
    if (i < 0 || j < 0 || j >= steps.length) return;
    const next = [...steps];
    [next[i], next[j]] = [next[j], next[i]];
    setSteps(next);
    setAnnouncement(`Moved to position ${j + 1} of ${steps.length}`);
    // Keep keyboard focus on the moved row.
    requestAnimationFrame(() => listRef.current?.querySelector<HTMLElement>(`[data-step="${id}"]`)?.focus());
  }

  async function regenerate() {
    if (!onRegenerate) return;
    setPhase("regenerating");
    setEditingId(null);
    try {
      const next = await onRegenerate();
      setSteps(next);
      setBaseline(next);
      setAnnouncement("New plan ready");
    } finally {
      setPhase("review");
    }
  }

  function approve() {
    const clean = steps.map((s) => ({ ...s, text: s.text.trim() })).filter((s) => s.text);
    setSteps(clean);
    setEditingId(null);
    setPhase("approved");
    onApprove(clean);
  }

  return (
    <section className={cn("w-full rounded-2xl border bg-card", className)} aria-label="Plan review">
      <header className="flex items-start justify-between gap-3 px-4 pt-4 pb-2">
        <div className="min-w-0">
          <h3 className="text-sm font-semibold">{phase === "approved" ? "Plan approved" : title}</h3>
          <p className="mt-0.5 text-xs text-muted-foreground">
            {phase === "approved"
              ? `Running ${steps.length} steps${estimate ? ` · ${estimate}` : ""}`
              : `Nothing runs until you approve.${estimate ? ` Estimated ${estimate}.` : ""}`}
          </p>
        </div>
        {changed && phase === "review" && (
          <span className="shrink-0 rounded-full border px-2 py-0.5 text-[10px] text-muted-foreground">Edited</span>
        )}
      </header>

      <Reorder.Group
        ref={listRef}
        as="ul"
        axis="y"
        values={steps}
        onReorder={setSteps}
        aria-label="Plan steps"
        className={cn("flex flex-col gap-1 px-2 transition-opacity", phase === "regenerating" && "opacity-40")}
      >
        <AnimatePresence initial={false}>
          {steps.map((step, i) => (
            <Row
              key={step.id}
              step={step}
              index={i}
              total={steps.length}
              locked={locked}
              keysId={keysId}
              editing={editingId === step.id}
              onEdit={() => setEditingId(step.id)}
              onDone={(text) => {
                if (text.trim()) update(step.id, text.trim());
                else remove(step.id);
                setEditingId(null);
              }}
              onRemove={() => remove(step.id)}
              onMove={(by) => move(step.id, by)}
            />
          ))}
        </AnimatePresence>
      </Reorder.Group>

      <span className="sr-only" aria-live="polite">
        {announcement}
      </span>
      <span id={keysId} className="sr-only">
        Enter to edit, Alt plus arrow keys to move, Delete to remove.
      </span>

      <footer className="mt-2 flex flex-wrap items-center justify-between gap-2 border-t px-4 py-3">
        {phase === "approved" ? (
          <span className="flex items-center gap-1.5 text-xs text-muted-foreground" role="status">
            <Check className="size-3.5" aria-hidden />
            Approved — the agent is working through these steps.
          </span>
        ) : (
          <>
            <div className="flex items-center gap-1">
              <button
                type="button"
                onClick={add}
                disabled={locked}
                className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
              >
                <Plus className="size-3.5" aria-hidden />
                Add step
              </button>
              {onRegenerate && (
                <button
                  type="button"
                  onClick={regenerate}
                  disabled={locked}
                  className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
                >
                  <RotateCcw className={cn("size-3.5", phase === "regenerating" && "animate-spin [animation-direction:reverse]")} aria-hidden />
                  {phase === "regenerating" ? "Rethinking…" : "New plan"}
                </button>
              )}
            </div>
            <button
              type="button"
              onClick={approve}
              disabled={locked || steps.every((s) => !s.text.trim())}
              className="rounded-xl bg-foreground px-4 py-1.5 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-40"
            >
              {approveLabel}
            </button>
          </>
        )}
      </footer>
    </section>
  );
}

function Row({
  step,
  index,
  total,
  locked,
  keysId,
  editing,
  onEdit,
  onDone,
  onRemove,
  onMove,
}: {
  step: PlanStep;
  index: number;
  total: number;
  locked: boolean;
  keysId: string;
  editing: boolean;
  onEdit: () => void;
  onDone: (text: string) => void;
  onRemove: () => void;
  onMove: (by: -1 | 1) => void;
}) {
  const controls = useDragControls();
  const [draft, setDraft] = React.useState(step.text);

  return (
    <Reorder.Item
      value={step}
      dragListener={false}
      dragControls={controls}
      initial={{ opacity: 0, height: 0 }}
      animate={{ opacity: 1, height: "auto" }}
      exit={{ opacity: 0, height: 0 }}
      transition={{ duration: 0.18 }}
      className="group relative list-none"
    >
      <div
        data-step={step.id}
        tabIndex={locked || editing ? -1 : 0}
        aria-label={`Step ${index + 1} of ${total}: ${step.text || "empty"}`}
        aria-describedby={locked ? undefined : keysId}
        onKeyDown={(e) => {
          if (locked || editing || e.target !== e.currentTarget) return;
          if (e.altKey && e.key === "ArrowUp") {
            e.preventDefault();
            onMove(-1);
          } else if (e.altKey && e.key === "ArrowDown") {
            e.preventDefault();
            onMove(1);
          } else if (e.key === "Enter") {
            e.preventDefault();
            setDraft(step.text);
            onEdit();
          } else if (e.key === "Delete" || e.key === "Backspace") {
            e.preventDefault();
            onRemove();
          }
        }}
        className="flex items-start gap-2 rounded-xl bg-card px-2 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring [.group:hover_&]:bg-muted/50"
      >
        {!locked && (
          <button
            type="button"
            tabIndex={-1}
            aria-hidden
            onPointerDown={(e) => controls.start(e)}
            className="mt-0.5 cursor-grab touch-none text-muted-foreground/60 hover:text-foreground active:cursor-grabbing"
          >
            <GripVertical className="size-4" />
          </button>
        )}
        <span
          className={cn(
            "mt-px flex size-5 shrink-0 items-center justify-center rounded-full border text-[11px] font-medium tabular-nums",
            locked && "border-foreground bg-foreground text-background"
          )}
          aria-hidden
        >
          {index + 1}
        </span>

        {editing ? (
          <input
            autoFocus
            value={draft}
            onChange={(e) => setDraft(e.target.value)}
            onBlur={() => onDone(draft)}
            onKeyDown={(e) => {
              if (e.key === "Enter") onDone(draft);
              if (e.key === "Escape") onDone(step.text);
            }}
            aria-label={`Edit step ${index + 1}`}
            placeholder="Describe the step"
            className="min-w-0 flex-1 rounded-md border bg-background px-2 py-0.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
          />
        ) : (
          <button
            type="button"
            tabIndex={-1}
            disabled={locked}
            onClick={() => {
              setDraft(step.text);
              onEdit();
            }}
            className="min-w-0 flex-1 cursor-text text-left text-sm text-pretty disabled:cursor-default"
          >
            {step.text}
          </button>
        )}

        {!locked && !editing && (
          <button
            type="button"
            tabIndex={-1}
            onClick={onRemove}
            aria-label={`Remove step ${index + 1}`}
            className="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-opacity hover:bg-accent hover:text-foreground [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100"
          >
            <X className="size-3.5" aria-hidden />
          </button>
        )}
      </div>
    </Reorder.Item>
  );
}

A UX spec for this pattern — written for agents implementing or reusing it, not the code.

# Plan Review

## Summary
Before an agent starts a long or expensive task, it shows the steps it intends to take as an editable list. The person can reword, reorder, remove or add steps, ask for a different plan, and then approve. Nothing runs until they do, and once approved the plan locks and becomes the record of what the agent was asked to do.

## When to use
- Agent runs that are slow, costly or hard to undo: building an app, generating a deck, a multi-source research run, a data migration.
- Tasks where the agent's interpretation can easily diverge from what was meant, so the cheapest moment to correct it is before any work.
- Contexts where people are accountable for what the agent does and need to sign off on it.

## When not to use
- Quick tasks where a wrong start costs seconds. Showing a plan for every request adds friction; show it above a cost or time threshold.
- Showing progress once the run has started — use Task List, which takes over from the approved plan.
- Approving one specific tool call — use Tool Approval.
- Gathering missing preferences — use Clarifying Questions first, then show the plan.

## Anatomy
- Header: title, a note that nothing runs until approval, an optional estimate, and an "Edited" tag once the plan has been changed.
- Step rows: a drag handle, a step number, the step text (click to edit) and a remove button.
- Footer: Add step, New plan, and the primary "Approve & run".
- Approved state: the plan locks, numbers fill in, and the footer confirms the agent is working.

## Behavior
- Steps can be dragged by their handle; with the keyboard, Alt+↑/↓ moves the focused step, Enter edits it, Delete removes it.
- Editing happens inline; Enter or leaving the field saves, Escape cancels, and clearing a step removes it.
- Add step appends an empty row in edit mode.
- New plan asks the agent for a fresh plan; the list dims and controls lock until it arrives, and edits to the old plan are discarded.
- Approve trims empty steps, locks the list and hands the final steps to the agent, which should follow them as written — if it has to deviate, it should say so in the Task List or trace.

## Content guidelines
- One outcome per step, as an imperative phrase: "Draft a 5-slide summary deck".
- Keep plans to three to seven steps; group detail inside steps rather than listing every tool call.
- Put the cost or time estimate in the header so people weigh the plan against it.
- Say plainly that nothing has run yet: "Nothing runs until you approve."

## Accessibility
- Steps are a list; each row is focusable with an accessible name including its position ("Step 2 of 4: …") and a description of the keyboard commands.
- Reordering has a keyboard path (Alt+arrow keys) as well as dragging, and moves are announced through a polite live region ("Moved to position 3 of 4").
- Remove and edit are reachable without a mouse; the remove button stays visible on touch screens.
- The approved state is announced as a status.

## Related patterns
- **Task List** — Use it as well; it shows progress through the approved plan once the agent starts.
- **Tool Approval** — Use it instead to approve a single action rather than a whole plan.
- **Clarifying Questions** — Use it as well, before the plan, when key details are missing.
- **Cost Estimate** — Use it as well when the plan's price should be weighed before approval.
- **Procedure Builder** — Use it instead for reusable procedures people author, rather than a one-off plan the agent proposes.

Do’s and don’ts

Do use it for

  • Agent runs that are slow, costly or hard to undo: building an app, generating a deck, a multi-source research run, a data migration.
  • Tasks where the agent's interpretation can easily diverge from what was meant, so the cheapest moment to correct it is before any work.
  • Contexts where people are accountable for what the agent does and need to sign off on it.

Don’t use it for

  • Quick tasks where a wrong start costs seconds. Showing a plan for every request adds friction; show it above a cost or time threshold.
  • Showing progress once the run has started — use Task List, which takes over from the approved plan.
  • Approving one specific tool call — use Tool Approval.
  • Gathering missing preferences — use Clarifying Questions first, then show the plan.