Plan
ProAn agent's plan. Proposed, it is the second turn of the two-turn rule: the person can drop steps they don't want, then accept or ask for changes. Accepted, it becomes the live run: each step shows its status, agent and the metadata it touches. A step waiting on another names what it waits for. Sent back, it shows the steps as they were proposed, marked as not accepted.
Drift: once the plan is running, a step can say how the run left the approved plan (drift on the step: added, skipped or changed), from your app's records. Each such step says so in words, and a line above the steps counts them ("The run left the approved plan: 1 step added, 1 step skipped").
Autonomy: with autonomy, accepting says what it allows. The person picks one option ("Ask before each edit", "Run without asking") before Accept is available; none is picked for them. onAccept gets the choice.
density="compact" fits it inside a thread: a smaller header, tighter rows, and a step's summary only while it's running or waiting (the "now" line); a failed step still shows why. actions go underneath the steps (Pause, Stop), in any mode.
Preview
Install
First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/plan
Brings in: agent-avatar, button, entity-chip, locale, locked, status, step-list, work, utils · npm: lucide-react@^1.67.0
Usage
"use client"
import * as React from "react"
import type { AgentIdentity } from "@/lib/chat"
import type { PlanStep } from "@/lib/work"
import { Plan } from "@/components/plan"
import { Button } from "@/components/ui/button"
const dev: AgentIdentity = { id: "a-dev", name: "Dev agent", role: "dev" }
const qa: AgentIdentity = { id: "a-qa", name: "QA agent", role: "qa" }
const proposed: PlanStep[] = [
{ id: "s1", title: "Add the region column", summary: "us, eu, apac, other; nullable; indexed.", status: "idle", agent: dev, touches: [{ kind: "migration", name: "0142_add_customer_region" }, { kind: "column", name: "customers.region", label: "region" }] },
{ id: "s2", title: "Show it on the customer page", status: "idle", agent: dev, dependsOn: ["s1"], touches: [{ kind: "component", name: "web/src/customers/CustomerHeader.tsx", label: "CustomerHeader" }] },
{ id: "s3", title: "Backfill from billing country", status: "idle", agent: dev, dependsOn: ["s1"] },
{ id: "s4", title: "Test on Staging", status: "idle", agent: qa, dependsOn: ["s2", "s3"] },
]
// In a thread: compact, the running step's summary as its "now" line, and actions underneath.
const running: PlanStep[] = [
{ id: "r1", title: "Add the region column", status: "succeeded", agent: dev },
{ id: "r2", title: "Backfill from billing country", summary: "4,120 of 9,800 customers so far.", status: "running", agent: dev },
{ id: "r3", title: "Test on Staging", summary: "Runs the checkout suite.", status: "idle", agent: qa, dependsOn: ["r2"] },
]
// Where the run left the approved plan, from the app's own records: Candor shows drift, it never works it out.
const drifted: PlanStep[] = [
{ id: "d1", title: "Add the region column", status: "succeeded", agent: dev },
{
id: "d2",
title: "Backfill from shipping country",
status: "succeeded",
agent: dev,
drift: { kind: "changed", from: "Backfill from billing country", note: "212 customers had no billing country." },
},
{ id: "d3", title: "Flag customers with no country", status: "running", agent: dev, drift: { kind: "added", note: "So someone can fill them in by hand." } },
{ id: "d4", title: "Show it on the customer page", status: "skipped", agent: dev, drift: { kind: "skipped", note: "Waiting on design." } },
{ id: "d5", title: "Test on Staging", status: "idle", agent: qa },
]
export default function Example() {
const [mode, setMode] = React.useState<"proposed" | "accepted" | "sent-back">("proposed")
const [steps, setSteps] = React.useState(proposed)
const [paused, setPaused] = React.useState(false)
const [granted, setGranted] = React.useState<string>()
return (
<div className="flex max-w-xl flex-col gap-6">
<Plan
title="Add region to customers"
steps={steps}
mode={mode}
autonomy={[{ id: "askBeforeEdits" }, { id: "runWithoutAsking" }]}
onAccept={(skipped, autonomy) => {
setGranted(autonomy)
setSteps((s) => s.map((x, i) => (skipped.includes(x.id) ? { ...x, status: "skipped" } : i === 0 ? { ...x, status: "running" } : x)))
setMode("accepted")
}}
onRevise={() => setMode("sent-back")}
/>
{granted && <p className="-mt-4 text-xs text-muted-foreground">onAccept got autonomy: {granted}</p>}
<Plan title="Add region to customers (running)" steps={drifted} mode="accepted" />
<Plan
density="compact"
title="Backfill customer regions"
steps={running}
mode="accepted"
actions={
<>
<Button variant="ghost" size="sm" onClick={() => setPaused((p) => !p)}>
{paused ? "Resume" : "Pause"}
</Button>
<Button variant="outline" size="sm">
Stop and keep what’s done
</Button>
</>
}
/>
</div>
)
}<Plan>
| Prop | Type | Default | Description |
|---|---|---|---|
| titlerequired | string | — | What the plan does, in a few words. |
| stepsrequired | PlanStep[] | — | The steps, in order. |
| moderequired | PlanMode | — | Proposed (waiting for a decision), accepted (the live run), or sent back for changes. |
| onAccept | ((skipped: string[], autonomy: string | undefined) => void) | undefined | — | Accept the proposed plan, with the ids of any steps the person dropped and, with `autonomy`, the id of the option they picked (undefined without `autonomy`). |
| autonomy | PlanAutonomyOption[] | undefined | — | What accepting can allow, in order. The person picks one before Accept is available; nothing is picked for them. Leave it out and accepting says nothing about autonomy, as before. |
| onRevise | (() => void) | undefined | — | Send the proposed plan back for changes. |
| acceptLocked | string | undefined | — | Why the viewer can't accept, if they can't. |
| activeStepId | string | undefined | — | The step shown as selected. |
| onSelectStep | ((id: string) => void) | undefined | — | Makes each step a button that selects it. |
| density | "default" | "compact" | undefined | "default" | "compact" for inside a thread: tighter, with a step's summary shown only while it's live. Defaults to "default". |
| actions | React.ReactNode | undefined | — | Buttons under the steps, such as Pause and Stop; beside Accept when proposed. |
| className | string | undefined | — | Extra classes for the outer element. |
Strings
The fixed words, in English by default. Change any of them under plan in <LocaleProvider strings={{ … }}>; the table is exported as planStrings.
| Key | English |
|---|---|
| label | Plan: {title} |
| proposed | Proposed plan |
| sentBack | Plan sent back for changes |
| plan | Plan |
| progress | {done} of {total} done |
| steps | { one: "{count} step", other: "{count} steps" } |
| agents | { one: "{count} agent", other: "{count} agents" } |
| sentBackSummary | {steps} · not accepted |
| proposedSummary | {steps} · {agents} |
| after | After {steps} |
| stepRef | step {number} |
| keep | Keep |
| skip | Skip |
| skipped | {count} skipped |
| askForChanges | Ask for changes |
| accept | Accept plan |
| driftSummary | The run left the approved plan: {changes} |
| driftAddedCount | { one: "{count} step added", other: "{count} steps added" } |
| driftSkippedCount | { one: "{count} step skipped", other: "{count} steps skipped" } |
| driftChangedCount | { one: "{count} step changed", other: "{count} steps changed" } |
| driftAdded | Added during the run, not in the approved plan |
| driftSkipped | Skipped by the run, though it was approved |
| driftChanged | Changed from the approved plan |
| driftFrom | Approved as: {from} |
| autonomyLabel | What accepting allows |
| autonomyChoose | Choose what accepting allows to accept the plan. |
| askBeforeEdits | Ask before each edit |
| askBeforeEditsDetail | The agent runs the plan and stops to ask before each change it makes. |
| runWithoutAsking | Run without asking |
| runWithoutAskingDetail | The agent runs every step you kept without stopping to ask. |
Types
- PlanMode
"proposed" | "accepted" | "sent-back"