PixelDosa UI

Agent Plan

The agent's plan laid out for agreement before anything runs — reorder, remove, or add a step, then run it as edited.

Proposed plan

5 steps · 1 will ask for approval

  1. Pull the 5 flagged invoices from Q3 Revenue Final.xlsx

  2. Match them against the 14 August import batch

    Reads ledger/august-import.json

  3. Check billing contacts for duplicates

  4. Draft a summary of the root cause

  5. Email the summary to finance@acme.com

    Asks for approval

Installation

bash
npx shadcn@latest add @pixeldosa/agent-plan

Requires `cn` at `@/lib/utils`. Mark any step with an external effect as `needsApproval` so the user sees where the run will pause before it starts. This component does not execute steps — it hands the agreed plan to `onRun`.

Usage

"use client";

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

<AgentPlan
  steps={[
    { id: "1", title: "Pull the 5 flagged invoices" },
    { id: "2", title: "Match them against the August import", detail: "Reads ledger/august-import.json" },
    { id: "3", title: "Email the summary to finance@acme.com", needsApproval: true },
  ]}
  onRun={(steps) => execute(steps)}
  onCancel={() => close()}
/>

Why it exists

A plan is where an agent's misunderstanding becomes visible while it's still free to fix. Once execution starts, a wrong step costs a tool call, a side effect, or a cancel and restart. This is the agreement, and then the record of what happened: give the steps a status and the same component follows the run, so people watch the plan they agreed to rather than a separate progress bar.

Features

  • Reorder, remove, add. Explicit up and down buttons rather than drag, so it works the same by keyboard, touch and mouse. Every edit is announced.
  • Removal is reversible. A removed step stays in place, struck through, with Undo — never silently deleted.
  • You know where it will pause. Steps with external effects are labelled "Asks for approval", and the header counts them.
  • Edits are visible. "Run edited plan" once anything changed; steps you added are marked "Added by you".
  • Holds still — it's your turn.
  • It follows the run. Once any step has a status, editing stops and the plan shows progress: the running step is marked and moves, a step waiting on you holds still in the waiting colour, finished steps get a check, and a failed step offers Retry.
  • Progress is counted, never invented. The bar measures finished steps out of the total, and Stop stays visible for the whole run.

Examples

Mark every step with an external effect, so the user sees the pauses before running:

const steps = agentPlan.map((step) => ({
  ...step,
  needsApproval: ["send_email", "update_record", "charge"].includes(step.tool),
}));

A fixed plan the user can trim and reorder but not extend:

<AgentPlan steps={steps} allowAdd={false} onRun={execute} />

Running the plan

Pass the same steps back with a status on each as the run moves. pending keeps a step quiet, running marks the current one, waiting means it needs you, and done, failed or skipped close it out. note explains a wait, a failure or a skip.

<AgentPlan
  steps={steps.map((step) => ({ ...step, status: run.status[step.id], note: run.note[step.id] }))}
  runStatus="Reading pricing pages"
  onRun={start}
  onStop={run.abort}
  onRetryStep={run.retry}
/>

Props

PropTypeDescription
steps{ id, title, detail?, needsApproval?, status?, note? }[]Required. The proposed plan, in order. A status on any step switches the component to its running view.
onRun(steps) => voidRequired. Receives the plan as edited.
onCancel() => void
titlestringDefaults to "Proposed plan".
allowAddbooleanDefaults to true.
runStatusstringWhat's happening right now, shown beside the progress count while running.
onStop() => voidShown throughout the run.
onRetryStep(stepId) => voidOffered on a failed step.
headingLevel2 | 3 | 4 | 5 | 6Heading level, so the component fits your page's outline. Defaults to 3.

Accessibility

  • An ordered list inside a labelled <section>.
  • Each step's controls are a labelled group; every icon button names the step it acts on ("Move 'Check billing contacts' up").
  • Moves, removals and additions are announced — including the new position, counting only steps that remain.
  • While running, each step is announced once as it finishes, fails, is skipped or starts waiting on you. A status that hasn't changed never speaks again. The current step carries aria-current="step", and every state is in words, not colour alone.
  • Icon buttons have a 44px tall hit area, expanded vertically only, so neighbouring controls don't overlap.

When not to use

Confirming what the agent understood before there's a plan is Intent Preview. For the approval itself when a step stops for one, use AI Approval Gate beside this component: the plan says a step is waiting, the gate states the consequence and takes the decision.

why it's built this way

Engineering Notes

Separates agreeing on a plan from executing it, because the two need opposite designs: agreement is a still, editable surface where the user is in control, while execution is a moving status surface where the machine is. Merging them produces the common pattern where a plan is shown only as it starts running, which is too late to change it cheaply. Removal is modelled as a reversible state rather than a deletion — the step remains in place, struck through, numbered out of the sequence, with Undo — so an edit is always visible and never destructive. Reordering deliberately uses explicit up and down buttons instead of drag and drop: drag is pointer-first, hard to make accessible, and ambiguous on touch, while buttons behave identically across every input method and announce the resulting position through a live region (counting only steps that remain). The icon buttons are 32 pixels with hit areas extended vertically to 44, not horizontally, because the three controls sit side by side and overlapping hit areas would turn a near-miss into the wrong edit. Steps flagged `needsApproval` are labelled and counted in the header, so where the run will pause is known before it starts rather than discovered during it. The running view lives in this component rather than a separate runner because the plan people agreed to is the thing they want to watch; a second surface would make them re-read a list they had already accepted. The switch is derived, not configured: the first step carrying a status past `pending` ends editing, so a caller cannot show edit controls over a plan that is already executing. Progress is counted from finished steps, never an invented percentage, and each step announces itself once as its status changes, tracked against the previous value so a re-render with unchanged statuses stays silent.

how it moves, and when it doesn't

Motion Notes

Still, as a user-turn surface: nothing moves on its own and nothing shimmers. Removed steps drop to reduced opacity with a strikethrough rather than animating out, so an edit reads as a changed state instead of a disappearance and remains reversible in place. Reordering changes position immediately without a sliding animation — a swap that animates on every press would slow repeated keyboard moves and add motion to what is a precise, deliberate edit. The remaining motion is the shared colour transition on hover and focus at `--pd-duration-instant`, which drops to instant under prefers-reduced-motion. In the running view only the current step moves: its mark pulses on the working colour. A step waiting on the person holds completely still in the waiting colour, which is the turn-taking grammar applied per row instead of per panel. The progress bar animates its width over `--pd-duration-slow` because it tracks a real, discrete change, and under reduced motion the pulse stops while colour and words continue to carry every state.