A live task plan for agents: a vertical timeline with pending, running, done, failed and skipped steps, nested substeps, expandable output, segmented progress and retry on failure.
Fix rounding in checkout
The total is off by one cent for some carts.
- Opened 14 files. Totals are built in lib/cart.ts and formatted in components/summary.tsx.
- Pending: Choose a fix
- Pending: Round once, at the end
- Pending: Store prices as integer cents
- Pending: Run the test suite
Working on: Read the repository
"use client"
import * as React from "react"
import { Pause, Play } from "lucide-react"
import { AgentPlan, type PlanStep } from "@/components/ballmac/agent-plan"
const base: PlanStep[] = [
{ id: "read", title: "Read the repository", description: "Find where checkout totals are computed.", status: "pending", detail: "Opened 14 files. Totals are built in lib/cart.ts and formatted in components/summary.tsx." },
{ id: "plan", title: "Choose a fix", status: "pending", steps: [
{ id: "plan-a", title: "Round once, at the end", status: "pending" },
{ id: "plan-b", title: "Store prices as integer cents", status: "pending" },
] },
{ id: "edit", title: "Edit the files", description: "Change 3 files and add a regression test.", status: "pending", detail: "lib/cart.ts +18 −9 · components/summary.tsx +4 −4 · cart.test.ts +31" },
{ id: "test", title: "Run the test suite", status: "pending" },
]
export default function AgentPlanDemo() {
const [tick, setTick] = React.useState(1)
const [playing, setPlaying] = React.useState(true)
React.useEffect(() => {
if (!playing) return
const id = setInterval(() => setTick((t) => (t >= 9 ? 9 : t + 1)), 1600)
return () => clearInterval(id)
}, [playing])
const order = ["read", "plan-a", "plan-b", "edit", "test"]
const at = Math.floor(tick / 2)
const status = (id: string) => {
const i = order.indexOf(id)
return i < at ? "done" : i === at ? "running" : "pending"
}
const steps = base.map((s): PlanStep => {
if (s.steps) {
const kids = s.steps.map((k): PlanStep => ({ ...k, status: status(k.id), duration: status(k.id) === "done" ? "1.2s" : undefined }))
const parent = kids.every((k) => k.status === "done") ? "done" : kids.some((k) => k.status !== "pending") ? "running" : "pending"
return { ...s, status: parent, steps: kids }
}
return { ...s, status: status(s.id), duration: status(s.id) === "done" ? "3.4s" : undefined }
})
return (
<div className="w-full max-w-lg">
<AgentPlan
title="Fix rounding in checkout"
description="The total is off by one cent for some carts."
steps={steps}
actions={
<button
type="button"
onClick={() => setPlaying((p) => !p)}
className="inline-flex h-8 items-center gap-1.5 rounded-md border bg-background px-2.5 text-[13px] font-medium shadow-xs outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
{playing ? <Pause aria-hidden="true" className="size-3.5" /> : <Play aria-hidden="true" className="size-3.5" />}
{playing ? "Pause" : "Resume"}
</button>
}
/>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/agent-planInstall the dependencies.
$ pnpm add motion@^12 lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/agent-plan.tsx// Ballmac UI: Agent Plan. https://ui.ballmac.com/components/agent-plan "use client" import * as React from "react" import { AlertCircle, Check, ChevronRight, Circle, Loader2, MinusCircle, RotateCw } from "lucide-react" import { AnimatePresence, motion, useReducedMotion } from "motion/react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type PlanStatus = "pending" | "running" | "done" | "failed" | "skipped" type PlanStep = { /** Unique id within the plan. */ id: string /** What the agent does in this step. */ title: string /** One line of context under the title. */ description?: string /** Current state of the step. */ status: PlanStatus /** How long it took or has taken, already formatted, such as "4.2s". */ duration?: string /** Extra content shown when the step is expanded: output, logs, a diff. */ detail?: React.ReactNode /** Why the step failed. Shown in words under the title. */ error?: string /** Nested steps. */ steps?: PlanStep[] } const STATUS_WORD: Record<PlanStatus, string> = { pending: "Pending", running: "Running", done: "Done", failed: "Failed", skipped: "Skipped", } /** Every step, parents before their substeps. */ function flatten(steps: PlanStep[]): PlanStep[] { return steps.flatMap((s) => [s, ...(s.steps ? flatten(s.steps) : [])]) } type AgentPlanProps = Omit<React.ComponentProps<"section">, "title"> & { /** The plan's steps, in order. Steps can nest one more level through `steps`. */ steps: PlanStep[] /** Heading of the plan. */ title?: string /** A sentence about the goal, under the heading. */ description?: string /** Ids of expanded steps (controlled). */ expanded?: string[] /** Called with the new list of expanded step ids. */ onExpandedChange?: (expanded: string[]) => void /** Called when the retry button of a failed step is pressed. The button only shows when this is set. */ onRetry?: (step: PlanStep) => void /** Content on the right of the heading, such as a Stop button. */ actions?: React.ReactNode } function StatusIcon({ status, reduce }: { status: PlanStatus; reduce: boolean | null }) { return ( <span aria-hidden="true" className={cn( "relative z-10 flex size-6 shrink-0 items-center justify-center rounded-full border bg-background transition-colors duration-200 motion-reduce:transition-none", status === "done" && "border-transparent bg-foreground text-background", status === "running" && "border-foreground", status === "failed" && "border-destructive text-destructive", status === "skipped" && "border-dashed text-muted-foreground", status === "pending" && "text-muted-foreground" )} > {status === "done" && ( <motion.span initial={reduce ? false : { scale: 0.4, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={{ type: "spring", stiffness: 420, damping: 22 }} className="flex" > <Check className="size-3.5" strokeWidth={3} /> </motion.span> )} {status === "running" && <Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" />} {status === "failed" && <AlertCircle className="size-3.5" />} {status === "skipped" && <MinusCircle className="size-3.5" />} {status === "pending" && <Circle className="size-1.5 fill-current" />} </span> ) } function AgentPlan({ steps, title, description, expanded: expandedProp, onExpandedChange, onRetry, actions, className, ...props }: AgentPlanProps) { const msg = useMessages() title ??= msg("agent-plan.title", "Plan") const reduce = useReducedMotion() const baseId = React.useId() const everyStep = React.useMemo(() => flatten(steps), [steps]) // Progress counts the units of work: steps without substeps. const all = React.useMemo(() => everyStep.filter((s) => !s.steps || s.steps.length === 0), [everyStep]) const [internal, setInternal] = React.useState<string[]>(() => everyStep.filter((s) => s.detail && (s.status === "running" || s.status === "failed")).map((s) => s.id) ) const expanded = expandedProp ?? internal const total = all.length const done = all.filter((s) => s.status === "done").length const failed = all.filter((s) => s.status === "failed").length const current = everyStep.find((s) => s.status === "running") const finished = total > 0 && all.every((s) => s.status === "done" || s.status === "skipped") function toggle(id: string) { const next = expanded.includes(id) ? expanded.filter((x) => x !== id) : [...expanded, id] setInternal(next) onExpandedChange?.(next) } const summary = finished ? "Plan complete" : current ? `Working on: ${current.title}` : failed ? `${failed} step${failed === 1 ? "" : "s"} failed` : `${done} of ${total} steps done` function renderStep(step: PlanStep, depth: number, isLast: boolean) { const open = expanded.includes(step.id) const expandable = !!step.detail const panelId = `${baseId}-${step.id}` const body = ( <> <span className="flex min-w-0 flex-1 flex-col gap-0.5"> <span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5"> <span className={cn( "text-sm leading-6 font-medium", step.status === "pending" || step.status === "skipped" ? "text-muted-foreground" : "text-foreground", step.status === "skipped" && "line-through decoration-muted-foreground/60" )} > {step.title} </span> {(step.status === "running" || step.status === "failed") && ( <span className={cn( "rounded-full border px-1.5 py-px text-[11px] leading-4 font-medium text-foreground", step.status === "failed" && "border-destructive/40 bg-destructive/10" )} > {STATUS_WORD[step.status]} </span> )} {step.duration && ( <span className="font-mono text-[11px] text-muted-foreground tabular-nums">{step.duration}</span> )} </span> {step.description && <span className="text-[13px] leading-5 text-muted-foreground">{step.description}</span>} </span> {expandable && ( <ChevronRight aria-hidden="true" className={cn( "mt-1 size-4 shrink-0 text-muted-foreground transition-transform duration-200 motion-reduce:transition-none", open ? "rotate-90" : "rtl:rotate-180" )} /> )} </> ) return ( <li key={step.id} data-status={step.status} className="relative flex gap-3 pb-4 last:pb-0"> {!isLast && ( <span aria-hidden="true" className={cn( "absolute top-6 bottom-0 start-3 -ms-px w-0.5 rounded-full", step.status === "done" ? "bg-foreground/80" : "bg-border" )} /> )} <StatusIcon status={step.status} reduce={reduce} /> <div className="min-w-0 flex-1"> {expandable ? ( <button type="button" aria-expanded={open} aria-controls={panelId} onClick={() => toggle(step.id)} className="-my-px flex w-full items-start gap-2 rounded-md text-start outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > <span className="sr-only">{`${STATUS_WORD[step.status]}:`}</span>{" "} {body} </button> ) : ( <div className="flex items-start gap-2"> <span className="sr-only">{`${STATUS_WORD[step.status]}:`}</span>{" "} {body} </div> )} {step.error && step.status === "failed" && ( <div className="mt-1.5 flex flex-wrap items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-2.5 py-2 text-[13px] text-foreground"> <AlertCircle aria-hidden="true" className="size-3.5 shrink-0 text-destructive" /> <span className="min-w-0 flex-1">{step.error}</span> {onRetry && ( <button type="button" onClick={() => onRetry(step)} aria-label={msg("agent-plan.retry", "Retry {title}", { title: step.title })} className="inline-flex h-7 items-center gap-1 rounded-md border bg-background px-2 text-xs font-medium outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50" > <RotateCw aria-hidden="true" className="size-3" /> {msg("agent-plan.retry", "Retry")} </button> )} </div> )} <AnimatePresence initial={false}> {expandable && open && ( <motion.div id={panelId} key="detail" initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }} animate={reduce ? { opacity: 1 } : { height: "auto", opacity: 1 }} exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }} transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }} className="overflow-hidden" > <div className="mt-2 rounded-lg border bg-muted/40 px-3 py-2.5 text-[13px] leading-5 text-foreground"> {step.detail} </div> </motion.div> )} </AnimatePresence> {step.steps && step.steps.length > 0 && ( <ol className="mt-3 list-none">{step.steps.map((s, i) => renderStep(s, depth + 1, i === step.steps!.length - 1))}</ol> )} </div> </li> ) } return ( <section data-slot="agent-plan" aria-label={title} className={cn("w-full rounded-xl border bg-card p-4 text-card-foreground sm:p-5", className)} {...props} > <header className="mb-4 flex flex-wrap items-start justify-between gap-3"> <div className="min-w-0"> <h3 className="text-sm font-semibold text-foreground">{title}</h3> {description && <p className="mt-0.5 text-[13px] leading-5 text-muted-foreground">{description}</p>} </div> {actions} </header> <div className="mb-4 flex items-center gap-3"> <div role="progressbar" aria-label={msg("agent-plan.progress", "{title} progress", { title })} aria-valuemin={0} aria-valuemax={total} aria-valuenow={done} aria-valuetext={`${done} of ${total} steps done`} className="flex h-1.5 flex-1 gap-0.5 overflow-hidden rounded-full" > {all.map((s) => ( <span key={s.id} aria-hidden="true" className={cn( "h-full flex-1 rounded-full transition-colors duration-300 motion-reduce:transition-none", s.status === "done" && "bg-foreground", s.status === "running" && "animate-pulse bg-foreground/50 motion-reduce:animate-none", s.status === "failed" && "bg-destructive", (s.status === "pending" || s.status === "skipped") && "bg-muted" )} /> ))} </div> <span className="font-mono text-xs text-muted-foreground tabular-nums"> {done}/{total} </span> </div> <ol className="list-none">{steps.map((s, i) => renderStep(s, 0, i === steps.length - 1))}</ol> <p className="sr-only" role="status" aria-live="polite"> {summary} </p> </section> ) } export { AgentPlan, type AgentPlanProps, type PlanStep, type PlanStatus }Update the import paths to match your project setup.
Usage
import { AgentPlan } from "@/components/ballmac/agent-plan"The full example is in the Code tab above.
Examples
Failure with retry
Monthly invoice summary
- Done: Fetch the latest invoices0.8s
- The webhook returned 503 after 3 attempts.Last response: Service Unavailable. The payload was not stored.
- Skipped: Notify the finance channel
1 step failed
"use client"
import * as React from "react"
import { AgentPlan, type PlanStep } from "@/components/ballmac/agent-plan"
const failed: PlanStep[] = [
{ id: "fetch", title: "Fetch the latest invoices", status: "done", duration: "0.8s" },
{ id: "parse", title: "Extract line items", status: "done", duration: "2.1s", detail: "Found 212 line items across 37 invoices." },
{
id: "send",
title: "Send the summary to accounting",
status: "failed",
description: "POST to the accounting webhook",
error: "The webhook returned 503 after 3 attempts.",
detail: "Last response: Service Unavailable. The payload was not stored.",
},
{ id: "notify", title: "Notify the finance channel", status: "skipped" },
]
export default function AgentPlanFailed() {
const [steps, setSteps] = React.useState(failed)
return (
<div className="w-full max-w-lg">
<AgentPlan
title="Monthly invoice summary"
steps={steps}
onRetry={(step) =>
setSteps((list) => list.map((s) => (s.id === step.id ? { ...s, status: "running", error: undefined } : s)))
}
/>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
steps*The plan's steps, in order. Steps can nest one more level through `steps`. | PlanStep[] | — |
titleHeading of the plan. | string | — |
descriptionA sentence about the goal, under the heading. | string | — |
expandedIds of expanded steps (controlled). | string[] | — |
onExpandedChangeCalled with the new list of expanded step ids. | (expanded: string[]) => void | — |
onRetryCalled when the retry button of a failed step is pressed. The button only shows when this is set. | (step: PlanStep) => void | — |
actionsContent on the right of the heading, such as a Stop button. | React.ReactNode | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Enter / Space | Expands or collapses a step's output |
| Screen readers | Each step reads its status first; a polite status line announces the current step |
| Color | Every state has an icon and, for running and failed, a word |
Use with AI
steps is [{ id, title, status, description?, duration?, detail?, error?, steps? }]. Update statuses as the agent works and the timeline, progress bar and screen reader summary follow. onRetry adds a Retry button to failed steps. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Agent Plan (@ballmac/agent-plan) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Showing what an agent will do and is doing
- Multi-step workflows that can fail part-way
Not for
- A single tool invocation; use tool-call-card
- Onboarding checklists; use onboarding-checklist
Registry JSON: https://ui.ballmac.com/r/agent-plan.json
Credits
Free to use in personal and commercial projects.
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Tool Call Card
Shows one agent tool call: the tool name, a pending, running, success or error status with icon and text, duration, and collapsible pretty-printed input and result.
Approval Card
A human-in-the-loop confirmation for agent actions: what will happen, risk level in words, the exact command, Approve, Deny and Always allow, a timeout countdown and a recorded decision.
Thinking Indicator
A waiting indicator for assistants with four animations (dots, bars, wave, shimmer), labels that take turns, an optional timer and one calm announcement for screen readers.
AI Chat
The layout for a chat UI: a message log that sticks to the bottom while replies stream unless the reader scrolls up, a jump-to-latest button, an empty state with suggestions and a footer.