AI

Agent Plan

An agent's plan while it is still a draft — ordered steps with their rationale, risk chips on the dangerous ones, per-step include/exclude, inline rewriting, a live duration and cost estimate for the steps you kept, and a one-shot Approve or Request changes.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Check,
  CircleAlert,
  CircleCheck,
  ClipboardList,
  Clock3,
  Coins,
  Info,
  ListChecks,
  Lock,
  MessageSquare,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/agent-plan.json

Prompt

Build a React + TypeScript + Tailwind "AgentPlan" component with zod and
lucide-react, on top of the shadcn Button and Badge primitives.

It renders the plan an agent proposes BEFORE it runs anything, at the one moment
where a human is still allowed to say no.

Contract
- A zod schema (`agentPlanProposalSchema` in a sibling contract file) is the
  single source of truth: { id, title, goal?, steps: Step[] } where Step is
  { id, text, rationale?, risk?: { level: "low"|"medium"|"high", note? },
    included: boolean, editable: boolean, required?: boolean,
    estimate?: { durationMs?: number, costUsd?: number } }.
- Nothing in the contract describes progress. There is no "running", no "done",
  no percentage: a plan that has started executing is a different object and a
  different component.
- `risk` is three levels, not a boolean — "reads a file" / "posts to a staging
  webhook" / "drops a production table" are three different decisions, and one
  warning colour for all of them trains people to stop reading warnings.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
  card's own render state. `status="error"` means the proposal failed to load,
  which is not the same failure as a plan you disagree with.
- Props: plan: Proposal | null; status; readOnly? (false); onToggleStep?(stepId,
  included); onEditStep?(stepId, text); onApprove?(planId, includedSteps);
  onRequestChanges?(planId, note | undefined, allSteps); onRetry?;
  showRationale? (true); minIncludedSteps? (1, clamped >= 0); formatDuration?;
  formatCost?; approveLabel?; emptyState?; errorMessage?; label? ("Proposed
  plan"); className plus the rest of the element props, ref forwarded to the
  <section>.

Behavior
- THE CARD OWNS THE DRAFT, THE CONSUMER OWNS THE PLAN. Inclusion and text live
  in the component as sparse overrides keyed by step id on top of the contract,
  so a step the reviewer never touched keeps following the data. `onToggleStep`
  and `onEditStep` are notifications, not a state machine the consumer has to
  mirror; the full result is handed over once, in the decision callback.
- Excluding a step RENUMBERS the run. The number in front of a step is its
  position in the run that would actually happen (1..n over included steps
  only), so dropping step 2 makes the old step 3 become step 2 instead of
  leaving a hole. Excluded rows show a dash, a strikethrough and a muted row
  tint, and stay in place — they are still part of the proposal.
- A `required: true` step renders a locked toggle (padlock instead of a tick)
  and is always counted as included; `included: false` on a required step is a
  contradiction the component resolves in favour of "required".
- Duplicate step ids are dropped, first occurrence wins. Both the inclusion
  override and the text edit are keyed by id, so a duplicate would make one
  toggle move two rows and one edit rewrite two steps.
- INLINE EDITING IS ONE EDITOR AT A TIME. Editing swaps the step text for a
  textarea; Cmd/Ctrl+Enter saves, Escape cancels, and closing it returns focus
  to the Edit button it replaced (handed over in the ref callback during the
  commit, not from an effect). Clicking Edit on a second row while the current
  draft is DIRTY is refused with a visible reason instead of silently discarding
  the rewrite; a clean draft switches straight over.
- An empty step is rejected on save: a step with no text is a deletion, and
  deletion already has a control — the include toggle. Typing the agent's
  original wording back drops the override entirely, so the "edited" marker only
  ever means "differs from what was proposed". A separate Revert restores it in
  one click.
- THE ESTIMATE COVERS THE KEPT STEPS ONLY and recomputes on every toggle — a
  total that still counts a step you dropped gets budgeted against. Steps
  carrying no estimate are NEVER treated as zero: they are counted and named
  ("1 step has no estimate"), so the total reads as "at least this much". A
  sub-cent total renders "< $0.01" rather than "$0.00", which would say "free"
  to someone about to approve spending.
- Included high-risk steps are summed into one destructive line above the
  actions ("2 high-risk steps are still in the run"). It informs, it does not
  block — the reviewer, not the component, decides what is acceptable.
- THE DECISION IS ONE-SHOT. Approve fires at most once per proposal id no matter
  how fast the button is clicked, because the lock is a ref read and written
  synchronously inside the handler: several clicks dispatched in one task all
  observe the same stale state, so a state-only guard would let the extras
  through and start the same run twice. The lock is keyed to a generation that
  ticks when a new proposal arrives, so it expires by itself.
- Approve is refused (with the reason on screen, and wired through
  aria-describedby) when fewer than `minIncludedSteps` steps are left, or while
  a step editor is open — approving would silently drop the unsaved rewrite.
  Both decision buttons use aria-disabled, never the native `disabled`
  attribute, which would drop focus to <body> mid-action and take the button out
  of the tab order.
- Requesting changes is two steps: the button reveals a note textarea, "Send
  request" submits it, and the callback receives the note (undefined when blank)
  plus ALL steps with the draft applied — what the reviewer dropped is half the
  feedback. If neither onApprove nor onRequestChanges is passed, no buttons are
  rendered at all: an Approve button that approves nothing is worse than none.
- After a decision the draft FREEZES: toggles keep their state and stay
  focusable but refuse changes, edit affordances disappear, and a summary line
  states what was sent. A plan you can still edit after approving it is not the
  plan that runs.
- A new proposal id resets everything — overrides, edits, the open editor, the
  note, the decision — by adjusting state during render rather than in an
  effect, so there is never a frame where the previous plan's draft is applied
  to a new one. Reusing the id while changing the steps leaves the reviewer's
  draft sitting on top of a plan they never saw; document that as a rule.
- A ready proposal with zero steps renders the EMPTY body on purpose: a plan
  with nothing in it is not a plan, and showing its title with an Approve button
  under it would invite approving nothing.

Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
  bg-primary + text-primary-foreground for the ticked toggle and Approve,
  bg-muted/30 for an excluded row, text-destructive with bg-destructive/5 and
  border-destructive/40 for a high-risk note and the request-changes path. No
  hard-coded colours anywhere.
- Risk is three SHAPES as well as three tones — shield / triangle / info — so it
  survives a monochrome theme and a colour-blind reader.
- The include control is a <button role="checkbox" aria-checked> labelled by the
  step text through aria-labelledby: this is "is this step part of the run", a
  selection, not a pushed state. A button answers both Space and Enter, which
  covers the checkbox key contract.
- The root is a <section aria-label="Proposed plan: <title>"> holding ONE
  persistent sr-only role="status" line ("4 of 7 steps in the run, about 12 min,
  about $0.40, 1 high risk"). It is mounted for the whole ready branch, so a
  toggle, an edit or a decision is announced from an element the screen reader
  is already watching.
- Long step text uses `wrap-anywhere` plus `min-w-0`, not `break-words`: only
  the former lowers the element's min-content width, which is what stops a
  300-character instruction from making the card wider than the panel it sits
  in. Nothing is line-clamped — the qualifiers at the end of a long step are the
  part a reviewer checks.
- Every transition is motion-reduce guarded and the loading skeleton stops
  pulsing under prefers-reduced-motion; no animation carries meaning.
- cn() merges the consumer's className into the root, which also spreads the
  remaining element props and forwards its ref.

Customization levers
- Density: `showRationale` drops the "Why:" line for a dense re-read of a plan
  you already understand; the risk note stays either way because it is the
  thing that changes a decision.
- Policy: `minIncludedSteps` decides how empty a plan may get before Approve
  refuses — 0 lets someone approve an empty run (a "do nothing" answer), 1 is
  the default, higher values suit a pipeline with mandatory stages.
- Units: `formatDuration` / `formatCost` swap the wording entirely — working
  days, credits, token counts, another currency. The defaults round to the unit
  a human says out loud ("about 24 min"), never to the millisecond, because an
  estimate is a guess and precision reads as a promise.
- Chrome: `label` names the region, `approveLabel` replaces the generated
  "Approve N steps", `emptyState` replaces the empty body, `errorMessage` +
  `onRetry` own the envelope failure.
- Mode: `readOnly` renders the same card as an archive — states visible,
  nothing changeable, no decision offered. Use it for a transcript of a plan
  that was already answered, or for a viewer without approval rights.
- Blocks you can drop: the goal line, the per-step estimate row and the header
  status badge are independent; removing any of them changes no behaviour. The
  footer estimate row and the risk summary are the two that carry the decision —
  cut those last.

Concepts

  • Review before execution, not progress after it — the whole card exists in the gap between "the agent has decided what to do" and "the agent starts doing it". That is why every step carries a rationale and no step carries a status: the reviewer is judging intent, and a spinner in this list would mean the decision came too late.
  • The card owns the draft, the app owns the plan — toggles and rewrites are local overrides keyed by step id, layered on top of the contract, so an untouched step keeps following your data while the reviewer works. The per-change callbacks are notifications; the complete result is handed over exactly once, when they decide.
  • Renumbering is the feedback — the number in front of a step is its position in the run that would actually happen, so excluding step 2 promotes everything below it instead of leaving a hole. It is the cheapest way to show that a toggle changed the plan and not just a checkbox.
  • An estimate you can trust is one that admits what it doesn't know — the total covers the kept steps only and recomputes on every toggle, and steps with no estimate are counted and named instead of quietly contributing zero. A sub-cent total says < $0.01, because "$0.00" reads as free to someone about to approve spending.
  • One-shot decision lock — the guard is a ref read and written synchronously inside the click handler, because several clicks dispatched in one task all observe the same stale state; a state-only guard would start the same run twice. It is keyed to a generation that ticks when a new proposal arrives, so it expires by itself rather than leaving a dead button behind.
  • Nothing is discarded silently — a second editor is refused while the first draft is dirty, Approve is refused while an editor is open, an empty step is rejected on save, and reverting an edit is always one click away. Every refusal states its reason on screen and through aria-describedby, because a button that does nothing is indistinguishable from a broken one.

On This Page