AI Agent Plan

What an agent intends to do — including when it changes its mind mid-run.

Step 2 of 4

  1. Find duplicate contacts, DoneMatched on email, 3 pairs found
  2. Merge into the oldest record, In progress
  3. Update the deal owner, Not started
  4. Notify the account manager, Not started

Installation

Terminal
pnpm dlx @dowel-ui/cli add ai-agent-plan

No 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

PropTypeDefault
label (required)

Names the plan, so the list is not an unlabelled sequence.

string
steps (required)PlanStep[]
quietCompleted

Completed steps recede, so the running one is what stands out.

booleanfalse
runningIndicator

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.

PlanRunningIndicator"pulse"

Plus every attribute of <div> except children.

AgentPlanSummary

Plus every attribute of <p>.

AgentPlanSteps

Plus every attribute of <ol>.

AgentPlanStep

PropTypeDefault
step (required)PlanStep
nested

Set on sub-steps. Not part of the public API surface.

booleanfalse

Plus every attribute of <li> except children.

Quality

8/8 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testeddoes not apply
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokenspasses
  • className mergedpasses
  • Visible focusdoes not apply
  • No fixed widthspasses

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.

ui/ai-agent-plan.tsx
"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>
  );
}