AI Agent Plan
What an agent intends to do — including when it changes its mind mid-run.
Step 2 of 4
- Find duplicate contacts, DoneMatched on email, 3 pairs found
- Merge into the oldest record, In progress
- Update the deal owner, Not started
- Notify the account manager, Not started
Installation
pnpm dlx @dowel-ui/cli add ai-agent-planNo npm packages are needed beyond what Dowel already requires.
Accessibility
An ordered list, because a plan is a sequence and the order carries meaning an unordered list would discard. The running step carries aria-current="step", which marks the reader's place without moving focus and fighting anyone reading ahead. Every status is stated in text as well as drawn, and the markers are aria-hidden so the status is not heard twice per step. The live region reports structural revisions only — announcing every status transition would talk over the reader continuously on a plan of any length. Drawn checks and crosses live inside the hidden markers; quietCompleted dims finished steps with a contrast-audited token, and the optional sweep is decoration that stops under reduced motion.
Props
AgentPlan
| Prop | Type | Default |
|---|---|---|
label (required)Names the plan, so the list is not an unlabelled sequence. | string | — |
steps (required) | PlanStep[] | — |
quietCompletedCompleted steps recede, so the running one is what stands out. | boolean | false |
runningIndicatorHow the running step is marked: | PlanRunningIndicator | "pulse" |
Plus every attribute of <div> except children.
AgentPlanSummary
Plus every attribute of <p>.
AgentPlanSteps
Plus every attribute of <ol>.
AgentPlanStep
| Prop | Type | Default |
|---|---|---|
step (required) | PlanStep | — |
nestedSet on sub-steps. Not part of the public API surface. | boolean | false |
Plus every attribute of <li> except children.
Quality
8/8 checks, measured from the source and its tests
- Tested — passes
- axe assertion — passes
- Keyboard tested — does not apply
- Storybook examples — passes
- Accessibility documented — passes
- Semantic tokens only — passes
- Motion from tokens — passes
- className merged — passes
- Visible focus — does not apply
- No fixed widths — passes
Used in
Whole screens assembled from this component. Installing one brings this and everything else it needs with it.
Source
This is exactly what dowel add ai-agent-plan writes into your project, with imports rewritten to your own path alias.
"use client";
// Motion from SmoothUI AI Task List (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
import {
createContext,
useContext,
useMemo,
useState,
type ComponentPropsWithRef,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
/**
* What an agent intends to do, and how far through it is.
*
* Not a stepper. A wizard's steps are decided up front and never change; an
* agent's plan is a hypothesis it revises as it learns — it adds a step when a
* lookup fails, drops one that turned out to be unnecessary, and reorders when
* a dependency appears. Every stepper treats its step list as fixed, so a plan
* rendered in one either silently mutates under the reader or re-renders from
* scratch and loses their place.
*
* So revision is the feature here, not an edge case. When the plan changes, the
* change is announced — "the model added a step" is information, and watching a
* list quietly grow is not the same as being told.
*
* Approval is not here. Deciding whether a plan may run is a different
* component with a different shape; this one displays and reports.
*/
export type StepStatus = "pending" | "running" | "done" | "failed" | "skipped";
export interface PlanStep {
id: string;
title: string;
status: StepStatus;
/** What the step will do, or what it found. Shown under the title. */
detail?: string;
/** Why it failed. Shown only when the status is "failed". */
error?: string;
/** One level of sub-steps. Deeper nesting is a tree, which this is not. */
steps?: PlanStep[];
/** A short end-aligned note, e.g. "12/12" or "3 files". Read after the title. */
note?: ReactNode;
}
const STATUS_LABEL: Record<StepStatus, string> = {
pending: "Not started",
running: "In progress",
done: "Done",
failed: "Failed",
skipped: "Skipped",
};
/** Flattens one level, so counting and "step N of M" include sub-steps. */
function flatten(steps: PlanStep[]): PlanStep[] {
return steps.flatMap((step) => [step, ...(step.steps ?? [])]);
}
function describeRevision(added: PlanStep[], removed: number): string {
const parts: string[] = [];
if (added.length === 1 && added[0]) parts.push(`Plan updated: added "${added[0].title}"`);
else if (added.length > 1) parts.push(`Plan updated: ${String(added.length)} steps added`);
if (removed === 1) parts.push("1 step removed");
else if (removed > 1) parts.push(`${String(removed)} steps removed`);
return parts.join(", ");
}
interface PlanContextValue {
steps: PlanStep[];
flat: PlanStep[];
currentIndex: number;
}
const PlanContext = createContext<PlanContextValue | null>(null);
/** How the running step is marked. */
export type PlanRunningIndicator = "pulse" | "sweep";
interface PlanOptions {
quietCompleted: boolean;
runningIndicator: PlanRunningIndicator;
}
/** Display options, separate from PlanContext so a lone AgentPlanStep still works. */
const PlanOptionsContext = createContext<PlanOptions>({
quietCompleted: false,
runningIndicator: "pulse",
});
function usePlanContext(component: string): PlanContextValue {
const context = useContext(PlanContext);
if (!context) {
throw new Error(`${component} must be rendered inside <AgentPlan>.`);
}
return context;
}
export interface AgentPlanProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
steps: PlanStep[];
/** Names the plan, so the list is not an unlabelled sequence. */
label: string;
children?: ReactNode;
/** Completed steps recede, so the running one is what stands out. */
quietCompleted?: boolean;
/**
* How the running step is marked: `pulse` pulses its marker; `sweep` runs a
* light along the bottom of its row instead — one moving thing at a time.
*/
runningIndicator?: PlanRunningIndicator;
}
export function AgentPlan({
className,
steps,
label,
children,
quietCompleted = false,
runningIndicator = "pulse",
...props
}: AgentPlanProps) {
const flat = useMemo(() => flatten(steps), [steps]);
const ids = flat.map((step) => step.id);
// Revision detection, adjusted during render rather than in an effect: an
// effect would announce a frame after the change is already on screen, and a
// ref read during render is unsafe under concurrent rendering.
const [seen, setSeen] = useState<string[]>(ids);
const [revision, setRevision] = useState("");
if (ids.join("\u0000") !== seen.join("\u0000")) {
const added = flat.filter((step) => !seen.includes(step.id));
const removed = seen.filter((id) => !ids.includes(id)).length;
setSeen(ids);
// A plan whose steps only changed status has not been revised. Only
// structural change is worth interrupting for.
setRevision(added.length > 0 || removed > 0 ? describeRevision(added, removed) : "");
}
const currentIndex = flat.findIndex((step) => step.status === "running");
const context = useMemo<PlanContextValue>(
() => ({ steps, flat, currentIndex }),
[steps, flat, currentIndex],
);
const options = useMemo<PlanOptions>(
() => ({ quietCompleted, runningIndicator }),
[quietCompleted, runningIndicator],
);
return (
<PlanContext.Provider value={context}>
<PlanOptionsContext.Provider value={options}>
<div
data-slot="agent-plan"
aria-label={label}
role="group"
className={cn("flex flex-col gap-2", className)}
{...props}
>
{children ?? (
<>
<AgentPlanSummary />
<AgentPlanSteps />
</>
)}
{/* Structural changes only. Announcing every status transition would
talk over the reader continuously on a plan of any length. */}
<span aria-live="polite" data-slot="agent-plan-revision" className="sr-only">
{revision}
</span>
</div>
</PlanOptionsContext.Provider>
</PlanContext.Provider>
);
}
/** "Step 3 of 6" plus the overall state, in words. */
export function AgentPlanSummary({ className, ...props }: ComponentPropsWithRef<"p">) {
const { flat, currentIndex } = usePlanContext("AgentPlanSummary");
const done = flat.filter((step) => step.status === "done").length;
const failed = flat.filter((step) => step.status === "failed").length;
const running = currentIndex >= 0;
const started = flat.some((step) => step.status !== "pending");
const message = !started
? `Proposed plan, ${String(flat.length)} steps`
: running
? `Step ${String(currentIndex + 1)} of ${String(flat.length)}`
: failed > 0
? `${String(done)} of ${String(flat.length)} done, ${String(failed)} failed`
: `${String(done)} of ${String(flat.length)} done`;
return (
<p
data-slot="agent-plan-summary"
className={cn("text-xs text-muted-foreground tabular-nums", className)}
{...props}
>
{message}
</p>
);
}
export function AgentPlanSteps({ className, ...props }: ComponentPropsWithRef<"ol">) {
const { steps } = usePlanContext("AgentPlanSteps");
return (
// An ordered list: a plan is a sequence, and the order carries meaning that
// an unordered list would throw away.
<ol
data-slot="agent-plan-steps"
className={cn("flex list-none flex-col gap-1", className)}
{...props}
>
{steps.map((step) => (
<AgentPlanStep key={step.id} step={step} />
))}
</ol>
);
}
export interface AgentPlanStepProps extends Omit<ComponentPropsWithRef<"li">, "children"> {
step: PlanStep;
/** Set on sub-steps. Not part of the public API surface. */
nested?: boolean;
}
export function AgentPlanStep({
className,
step,
nested = false,
...props
}: AgentPlanStepProps) {
const { status, title, detail, error, note, steps: children } = step;
const { quietCompleted, runningIndicator } = useContext(PlanOptionsContext);
const sweeping = runningIndicator === "sweep" && status === "running";
return (
<li
data-slot="agent-plan-step"
data-status={status}
// Marks where the reader is in the sequence without moving focus, which
// would fight anyone reading ahead.
aria-current={status === "running" ? "step" : undefined}
className={cn("flex flex-col gap-1", nested && "ms-5", className)}
{...props}
>
<div className={cn("flex items-start gap-2", sweeping && "relative")}>
<StepMarker status={status} pulse={runningIndicator === "pulse"} />
<div className="flex min-w-0 flex-1 flex-col">
<span
className={cn(
"text-sm",
status === "pending" && "text-muted-foreground",
status === "skipped" && "text-muted-foreground line-through",
status === "failed" && "text-destructive",
status === "running" && "font-medium",
// A token rather than opacity, so the contrast audit covers it.
quietCompleted &&
"transition-[color,translate] duration-[calc(250ms*var(--motion-scale))] ease-[var(--ease-out-quint)]",
quietCompleted && status === "done" && "translate-y-px text-muted-foreground",
)}
>
{title}
{/* Status in text, always. The marker is a shape and a colour, and
neither is information on its own. */}
<span className="sr-only">, {STATUS_LABEL[status]}</span>
</span>
{detail ? <span className="text-xs text-muted-foreground">{detail}</span> : null}
{status === "failed" && error ? (
<span data-slot="agent-plan-error" className="text-xs text-destructive">
{error}
</span>
) : null}
</div>
{note !== undefined && note !== null ? (
<span
data-slot="agent-plan-note"
className="ms-auto shrink-0 text-xs text-muted-foreground tabular-nums"
>
{note}
</span>
) : null}
{sweeping ? (
// Decoration, like the pulse it replaces: the step already says "In
// progress" and carries aria-current, so this stops under reduced
// motion rather than being exempted as an indicator.
<span
aria-hidden="true"
data-slot="agent-plan-sweep"
className="pointer-events-none absolute inset-x-0 bottom-0 h-px opacity-50"
style={{
backgroundImage:
"linear-gradient(90deg, transparent, currentColor 50%, transparent)",
backgroundSize: "50% 100%",
backgroundRepeat: "no-repeat",
}}
/>
) : null}
</div>
{children && children.length > 0 ? (
<ol className="flex list-none flex-col gap-1">
{children.map((child) => (
<AgentPlanStep key={child.id} step={child} nested />
))}
</ol>
) : null}
</li>
);
}
const PREFIX = "dowel-ai-agent-plan";
/* The check and cross draw in when a step finishes; the sweep travels along
* the running row, and runs the other way in RTL. */
const STYLES = `
@keyframes ${PREFIX}-draw{from{stroke-dashoffset:1}}
@keyframes ${PREFIX}-sweep{from{background-position-x:-100%}to{background-position-x:200%}}
[data-slot=agent-plan-marker] [data-part=glyph]{stroke-dasharray:1 1;animation:${PREFIX}-draw calc(200ms * var(--motion-scale,1)) var(--ease-out-quint) both}
[data-slot=agent-plan-sweep]{animation:${PREFIX}-sweep calc(1400ms * var(--motion-scale,1)) var(--ease-in-out-quint) infinite}
[data-slot=agent-plan-sweep]:dir(rtl){animation-direction:reverse}
`;
const CHECK_PATH = "M 3.5 7.5 L 6 10 L 10.5 4.5";
const CROSS_PATH = "M 5 5 L 9 9 M 9 5 L 5 9";
/** A drawn glyph. Mounts when the status changes, which is what plays the draw. */
function MarkerGlyph({ d }: { d: string }) {
return (
<svg viewBox="0 0 14 14" fill="none" className="size-3">
<path
data-part="glyph"
d={d}
pathLength={1}
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
/**
* The status shape.
*
* aria-hidden throughout: every marker repeats what the step already states in
* text, and announcing both means hearing the status twice per step.
*/
function StepMarker({ status, pulse }: { status: StepStatus; pulse: boolean }) {
const base = "mt-0.5 grid size-4 shrink-0 place-items-center rounded-full border text-[9px]";
const sheet = (
<style href={PREFIX} precedence="dowel">
{STYLES}
</style>
);
if (status === "running") {
return (
// Deliberately not data-motion="indicator". The bar for that exemption is
// that stopping the animation would say something false, and it would
// not: the step already states "In progress" in text and carries
// aria-current. This pulse is reinforcement, so under reduced motion it
// stops like any other decoration.
<span
aria-hidden="true"
data-slot="agent-plan-marker"
className={cn(
base,
pulse && "animate-pulse-soft",
"border-primary bg-primary/15 text-primary",
)}
>
{sheet}
<span className="size-1.5 rounded-full bg-primary" />
</span>
);
}
return (
<span
aria-hidden="true"
data-slot="agent-plan-marker"
className={cn(
base,
status === "done" && "border-success bg-success text-success-foreground",
status === "failed" && "border-destructive bg-destructive text-destructive-foreground",
status === "skipped" && "border-border-strong text-muted-foreground",
status === "pending" && "border-border-strong",
)}
>
{sheet}
{status === "done" ? (
<MarkerGlyph key="done" d={CHECK_PATH} />
) : status === "failed" ? (
<MarkerGlyph key="failed" d={CROSS_PATH} />
) : status === "skipped" ? (
"–"
) : null}
</span>
);
}