{"slug":"plan-review","category":"agents","job":"oversee","added":"2026-11-07T23:13Z","title":"Plan Review","description":"An editable plan shown before an agent runs — reword, reorder, remove or add steps, or ask for a new plan — that locks once approved.","url":"https://ai-patterns.app/patterns/agents/plan-review","markdown":"https://ai-patterns.app/patterns/agents/plan-review.md","json":"https://ai-patterns.app/api/patterns/agents/plan-review","seenIn":[{"team":"Cursor","source":"https://cursor.com/docs/agent/plan-mode"},{"team":"Replit","source":"https://docs.replit.com/features/agent/plan-mode"},{"team":"zapier","source":"https://help.zapier.com/hc/en-us/articles/15703650952077-Use-the-power-of-AI-to-generate-Zap-workflows"}],"spec":"# Plan Review\n\n## Summary\nBefore 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.\n\n## When to use\n- Agent runs that are slow, costly or hard to undo: building an app, generating a deck, a multi-source research run, a data migration.\n- Tasks where the agent's interpretation can easily diverge from what was meant, so the cheapest moment to correct it is before any work.\n- Contexts where people are accountable for what the agent does and need to sign off on it.\n\n## When not to use\n- Quick tasks where a wrong start costs seconds. Showing a plan for every request adds friction; show it above a cost or time threshold.\n- Showing progress once the run has started — use Task List, which takes over from the approved plan.\n- Approving one specific tool call — use Tool Approval.\n- Gathering missing preferences — use Clarifying Questions first, then show the plan.\n\n## Anatomy\n- Header: title, a note that nothing runs until approval, an optional estimate, and an \"Edited\" tag once the plan has been changed.\n- Step rows: a drag handle, a step number, the step text (click to edit) and a remove button.\n- Footer: Add step, New plan, and the primary \"Approve & run\".\n- Approved state: the plan locks, numbers fill in, and the footer confirms the agent is working.\n\n## Behavior\n- Steps can be dragged by their handle; with the keyboard, Alt+↑/↓ moves the focused step, Enter edits it, Delete removes it.\n- Editing happens inline; Enter or leaving the field saves, Escape cancels, and clearing a step removes it.\n- Add step appends an empty row in edit mode.\n- 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.\n- 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.\n\n## Content guidelines\n- One outcome per step, as an imperative phrase: \"Draft a 5-slide summary deck\".\n- Keep plans to three to seven steps; group detail inside steps rather than listing every tool call.\n- Put the cost or time estimate in the header so people weigh the plan against it.\n- Say plainly that nothing has run yet: \"Nothing runs until you approve.\"\n\n## Accessibility\n- 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.\n- 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\").\n- Remove and edit are reachable without a mouse; the remove button stays visible on touch screens.\n- The approved state is announced as a status.\n\n## Related patterns\n- **Task List** — Use it as well; it shows progress through the approved plan once the agent starts.\n- **Tool Approval** — Use it instead to approve a single action rather than a whole plan.\n- **Clarifying Questions** — Use it as well, before the plan, when key details are missing.\n- **Cost Estimate** — Use it as well when the plan's price should be weighed before approval.\n- **Procedure Builder** — Use it instead for reusable procedures people author, rather than a one-off plan the agent proposes.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, Reorder, useDragControls } from \"motion/react\";\nimport { Check, GripVertical, Plus, RotateCcw, X } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface PlanStep {\n  id: string;\n  text: string;\n}\n\nexport interface PlanReviewProps {\n  title?: string;\n  steps: PlanStep[];\n  /** Rough cost or duration of running the plan (\"~6 min · 40 credits\"). */\n  estimate?: string;\n  onApprove: (steps: PlanStep[]) => void;\n  /** Asks the agent for a fresh plan. */\n  onRegenerate?: () => Promise<PlanStep[]>;\n  approveLabel?: string;\n  className?: string;\n}\n\ntype Phase = \"review\" | \"regenerating\" | \"approved\";\n\nexport function PlanReview({\n  title = \"Here's my plan\",\n  steps: initialSteps,\n  estimate,\n  onApprove,\n  onRegenerate,\n  approveLabel = \"Approve & run\",\n  className,\n}: PlanReviewProps) {\n  const [steps, setSteps] = React.useState(initialSteps);\n  // What \"Edited\" compares against: the agent's latest plan.\n  const [baseline, setBaseline] = React.useState(initialSteps);\n  const [phase, setPhase] = React.useState<Phase>(\"review\");\n  const [editingId, setEditingId] = React.useState<string | null>(null);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n  const counter = React.useRef(0);\n  const listRef = React.useRef<HTMLUListElement>(null);\n  const keysId = React.useId();\n\n  const changed = JSON.stringify(steps) !== JSON.stringify(baseline);\n  const locked = phase !== \"review\";\n\n  function update(id: string, text: string) {\n    setSteps((s) => s.map((step) => (step.id === id ? { ...step, text } : step)));\n  }\n\n  function remove(id: string) {\n    setSteps((s) => s.filter((step) => step.id !== id));\n    setAnnouncement(\"Step removed\");\n  }\n\n  function add() {\n    const id = `new-${++counter.current}`;\n    setSteps((s) => [...s, { id, text: \"\" }]);\n    setEditingId(id);\n  }\n\n  function move(id: string, by: -1 | 1) {\n    const i = steps.findIndex((step) => step.id === id);\n    const j = i + by;\n    if (i < 0 || j < 0 || j >= steps.length) return;\n    const next = [...steps];\n    [next[i], next[j]] = [next[j], next[i]];\n    setSteps(next);\n    setAnnouncement(`Moved to position ${j + 1} of ${steps.length}`);\n    // Keep keyboard focus on the moved row.\n    requestAnimationFrame(() => listRef.current?.querySelector<HTMLElement>(`[data-step=\"${id}\"]`)?.focus());\n  }\n\n  async function regenerate() {\n    if (!onRegenerate) return;\n    setPhase(\"regenerating\");\n    setEditingId(null);\n    try {\n      const next = await onRegenerate();\n      setSteps(next);\n      setBaseline(next);\n      setAnnouncement(\"New plan ready\");\n    } finally {\n      setPhase(\"review\");\n    }\n  }\n\n  function approve() {\n    const clean = steps.map((s) => ({ ...s, text: s.text.trim() })).filter((s) => s.text);\n    setSteps(clean);\n    setEditingId(null);\n    setPhase(\"approved\");\n    onApprove(clean);\n  }\n\n  return (\n    <section className={cn(\"w-full rounded-2xl border bg-card\", className)} aria-label=\"Plan review\">\n      <header className=\"flex items-start justify-between gap-3 px-4 pt-4 pb-2\">\n        <div className=\"min-w-0\">\n          <h3 className=\"text-sm font-semibold\">{phase === \"approved\" ? \"Plan approved\" : title}</h3>\n          <p className=\"mt-0.5 text-xs text-muted-foreground\">\n            {phase === \"approved\"\n              ? `Running ${steps.length} steps${estimate ? ` · ${estimate}` : \"\"}`\n              : `Nothing runs until you approve.${estimate ? ` Estimated ${estimate}.` : \"\"}`}\n          </p>\n        </div>\n        {changed && phase === \"review\" && (\n          <span className=\"shrink-0 rounded-full border px-2 py-0.5 text-[10px] text-muted-foreground\">Edited</span>\n        )}\n      </header>\n\n      <Reorder.Group\n        ref={listRef}\n        as=\"ul\"\n        axis=\"y\"\n        values={steps}\n        onReorder={setSteps}\n        aria-label=\"Plan steps\"\n        className={cn(\"flex flex-col gap-1 px-2 transition-opacity\", phase === \"regenerating\" && \"opacity-40\")}\n      >\n        <AnimatePresence initial={false}>\n          {steps.map((step, i) => (\n            <Row\n              key={step.id}\n              step={step}\n              index={i}\n              total={steps.length}\n              locked={locked}\n              keysId={keysId}\n              editing={editingId === step.id}\n              onEdit={() => setEditingId(step.id)}\n              onDone={(text) => {\n                if (text.trim()) update(step.id, text.trim());\n                else remove(step.id);\n                setEditingId(null);\n              }}\n              onRemove={() => remove(step.id)}\n              onMove={(by) => move(step.id, by)}\n            />\n          ))}\n        </AnimatePresence>\n      </Reorder.Group>\n\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {announcement}\n      </span>\n      <span id={keysId} className=\"sr-only\">\n        Enter to edit, Alt plus arrow keys to move, Delete to remove.\n      </span>\n\n      <footer className=\"mt-2 flex flex-wrap items-center justify-between gap-2 border-t px-4 py-3\">\n        {phase === \"approved\" ? (\n          <span className=\"flex items-center gap-1.5 text-xs text-muted-foreground\" role=\"status\">\n            <Check className=\"size-3.5\" aria-hidden />\n            Approved — the agent is working through these steps.\n          </span>\n        ) : (\n          <>\n            <div className=\"flex items-center gap-1\">\n              <button\n                type=\"button\"\n                onClick={add}\n                disabled={locked}\n                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\"\n              >\n                <Plus className=\"size-3.5\" aria-hidden />\n                Add step\n              </button>\n              {onRegenerate && (\n                <button\n                  type=\"button\"\n                  onClick={regenerate}\n                  disabled={locked}\n                  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\"\n                >\n                  <RotateCcw className={cn(\"size-3.5\", phase === \"regenerating\" && \"animate-spin [animation-direction:reverse]\")} aria-hidden />\n                  {phase === \"regenerating\" ? \"Rethinking…\" : \"New plan\"}\n                </button>\n              )}\n            </div>\n            <button\n              type=\"button\"\n              onClick={approve}\n              disabled={locked || steps.every((s) => !s.text.trim())}\n              className=\"rounded-xl bg-foreground px-4 py-1.5 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-40\"\n            >\n              {approveLabel}\n            </button>\n          </>\n        )}\n      </footer>\n    </section>\n  );\n}\n\nfunction Row({\n  step,\n  index,\n  total,\n  locked,\n  keysId,\n  editing,\n  onEdit,\n  onDone,\n  onRemove,\n  onMove,\n}: {\n  step: PlanStep;\n  index: number;\n  total: number;\n  locked: boolean;\n  keysId: string;\n  editing: boolean;\n  onEdit: () => void;\n  onDone: (text: string) => void;\n  onRemove: () => void;\n  onMove: (by: -1 | 1) => void;\n}) {\n  const controls = useDragControls();\n  const [draft, setDraft] = React.useState(step.text);\n\n  return (\n    <Reorder.Item\n      value={step}\n      dragListener={false}\n      dragControls={controls}\n      initial={{ opacity: 0, height: 0 }}\n      animate={{ opacity: 1, height: \"auto\" }}\n      exit={{ opacity: 0, height: 0 }}\n      transition={{ duration: 0.18 }}\n      className=\"group relative list-none\"\n    >\n      <div\n        data-step={step.id}\n        tabIndex={locked || editing ? -1 : 0}\n        aria-label={`Step ${index + 1} of ${total}: ${step.text || \"empty\"}`}\n        aria-describedby={locked ? undefined : keysId}\n        onKeyDown={(e) => {\n          if (locked || editing || e.target !== e.currentTarget) return;\n          if (e.altKey && e.key === \"ArrowUp\") {\n            e.preventDefault();\n            onMove(-1);\n          } else if (e.altKey && e.key === \"ArrowDown\") {\n            e.preventDefault();\n            onMove(1);\n          } else if (e.key === \"Enter\") {\n            e.preventDefault();\n            setDraft(step.text);\n            onEdit();\n          } else if (e.key === \"Delete\" || e.key === \"Backspace\") {\n            e.preventDefault();\n            onRemove();\n          }\n        }}\n        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\"\n      >\n        {!locked && (\n          <button\n            type=\"button\"\n            tabIndex={-1}\n            aria-hidden\n            onPointerDown={(e) => controls.start(e)}\n            className=\"mt-0.5 cursor-grab touch-none text-muted-foreground/60 hover:text-foreground active:cursor-grabbing\"\n          >\n            <GripVertical className=\"size-4\" />\n          </button>\n        )}\n        <span\n          className={cn(\n            \"mt-px flex size-5 shrink-0 items-center justify-center rounded-full border text-[11px] font-medium tabular-nums\",\n            locked && \"border-foreground bg-foreground text-background\"\n          )}\n          aria-hidden\n        >\n          {index + 1}\n        </span>\n\n        {editing ? (\n          <input\n            autoFocus\n            value={draft}\n            onChange={(e) => setDraft(e.target.value)}\n            onBlur={() => onDone(draft)}\n            onKeyDown={(e) => {\n              if (e.key === \"Enter\") onDone(draft);\n              if (e.key === \"Escape\") onDone(step.text);\n            }}\n            aria-label={`Edit step ${index + 1}`}\n            placeholder=\"Describe the step\"\n            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\"\n          />\n        ) : (\n          <button\n            type=\"button\"\n            tabIndex={-1}\n            disabled={locked}\n            onClick={() => {\n              setDraft(step.text);\n              onEdit();\n            }}\n            className=\"min-w-0 flex-1 cursor-text text-left text-sm text-pretty disabled:cursor-default\"\n          >\n            {step.text}\n          </button>\n        )}\n\n        {!locked && !editing && (\n          <button\n            type=\"button\"\n            tabIndex={-1}\n            onClick={onRemove}\n            aria-label={`Remove step ${index + 1}`}\n            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\"\n          >\n            <X className=\"size-3.5\" aria-hidden />\n          </button>\n        )}\n      </div>\n    </Reorder.Item>\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}