An agent's plan against what happened: every step with its state in words, expected and actual time, a bar of the whole plan, an honest note when steps were added or skipped, and how far a step ran over.
Fix the invoice total
2 of 5 steps done · about 8.5 min left
The plan changed1 added1 skipped
Done: Read the ticket and the three linked threads
1 min / 1 min
Done: Find where invoice totals are calculated
The totals live in two places, so this took longer.
4.5 min / 2 minover by 2.5 min
In progress: Write the fix in the shared helper
Editing the rounding in the totals helper.
In progress
2.5 min / 6 min
To do: Update the snapshot testsAdded by the agent
Added after step 2: two snapshots depend on the old rounding.
expected 3 min
Skipped: Write release notes
Skipped: a bug fix does not need them.
To do: Open a pull request
expected 2 min
"use client"
import { PlanTracker, type PlanStep } from "@/components/ballmac/plan-tracker"
const steps: PlanStep[] = [
{ id: "1", title: "Read the ticket and the three linked threads", status: "done", estimate: 1, actual: 1 },
{ id: "2", title: "Find where invoice totals are calculated", status: "done", estimate: 2, actual: 4.5, note: "The totals live in two places, so this took longer." },
{ id: "3", title: "Write the fix in the shared helper", status: "doing", estimate: 6, actual: 2.5, note: "Editing the rounding in the totals helper." },
{ id: "4", title: "Update the snapshot tests", status: "todo", estimate: 3, origin: "added", note: "Added after step 2: two snapshots depend on the old rounding." },
{ id: "5", title: "Write release notes", status: "skipped", note: "Skipped: a bug fix does not need them." },
{ id: "6", title: "Open a pull request", status: "todo", estimate: 2 },
]
export default function PlanTrackerDemo() {
return (
<div className="w-full max-w-xl">
<PlanTracker steps={steps} title="Fix the invoice total" />
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/plan-trackerInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18n @ballmac/motion-presetsCopy the source into your project.
components/ballmac/plan-tracker.tsx// Ballmac UI: Plan Tracker. https://ui.ballmac.com/components/plan-tracker "use client" import * as React from "react" import { Ban, Check, CircleAlert, CircleDashed, Loader2, Plus } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" import { useLoopsPaused } from "@/lib/ballmac/motion" type PlanStatus = "todo" | "doing" | "done" | "skipped" | "blocked" type PlanStep = { id: string title: string status: PlanStatus /** "added" for a step the agent put in after the plan was made. */ origin?: "planned" | "added" /** How long it was expected to take, in minutes. */ estimate?: number /** How long it has taken so far, in minutes. */ actual?: number /** A line under the title: what it is doing, why it was skipped, what blocks it. */ note?: string } type PlanTrackerProps = Omit<React.ComponentProps<"section">, "title"> & { steps: PlanStep[] /** Heading. */ title?: string } const STATUS = { todo: { icon: CircleDashed, label: "To do" }, doing: { icon: Loader2, label: "In progress" }, done: { icon: Check, label: "Done" }, skipped: { icon: Ban, label: "Skipped" }, blocked: { icon: CircleAlert, label: "Blocked" }, } as const /** 0.4 → "under 1 min", 3 → "3 min", 4.5 → "4.5 min". */ function formatMinutes(m: number) { if (m < 1) return "under 1 min" return `${+m.toFixed(1)} min` } /** Minutes the plan still needs: steps not started, plus what is left of the one in progress. Steps with no estimate count as nothing. */ function remainingMinutes(steps: PlanStep[]) { return steps.reduce((n, s) => { if (s.status === "todo") return n + (s.estimate ?? 0) if (s.status === "doing") return n + Math.max(0, (s.estimate ?? 0) - (s.actual ?? 0)) return n }, 0) } /** * An agent's plan against what actually happened: every step with its state in words, how long it was expected to take and how long it has taken, a bar * of the whole plan, and an honest note when the plan changed (steps the agent added, steps it skipped). A step that runs well over its estimate says * by how much. It is a plain list, so it reads in order and needs no keyboard handling of its own. */ function PlanTracker({ steps, title, className, ...props }: PlanTrackerProps) { const msg = useMessages() const paused = useLoopsPaused() const id = React.useId() title ??= msg("plan-tracker.title", "Plan") const skipped = steps.filter((s) => s.status === "skipped").length const added = steps.filter((s) => s.origin === "added").length const done = steps.filter((s) => s.status === "done").length const counted = steps.length - skipped const left = remainingMinutes(steps) const finished = counted > 0 && done === counted const spoken = msg("plan-tracker.spoken", "{done} of {total} steps done", { done, total: counted }) return ( <section data-slot="plan-tracker" aria-labelledby={`${id}-t`} className={cn("bg-card text-card-foreground grid gap-4 rounded-xl border p-4 sm:p-5", className)} {...props}> <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1"> <h3 id={`${id}-t`} className="text-sm font-semibold">{title}</h3> <p className="text-xs tabular-nums text-muted-foreground"> {spoken} {!finished && left > 0 ? ` · ${msg("plan-tracker.left", "about {time} left", { time: formatMinutes(left) })}` : ""} {finished ? ` · ${msg("plan-tracker.complete", "complete")}` : ""} </p> </div> <div role="progressbar" aria-labelledby={`${id}-t`} aria-valuemin={0} aria-valuemax={counted} aria-valuenow={done} aria-valuetext={spoken} className="flex h-1.5 gap-0.5"> {steps.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 === "doing" && "bg-chart-1", s.status === "blocked" && "bg-destructive", s.status === "todo" && "bg-muted", s.status === "skipped" && "bg-[repeating-linear-gradient(135deg,var(--muted-foreground)_0_2px,transparent_2px_5px)] opacity-40", s.status === "doing" && !paused && "animate-pulse")} /> ))} </div> {(added > 0 || skipped > 0) && ( <p className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <span className="font-medium text-foreground">{msg("plan-tracker.changed", "The plan changed")}</span> {added > 0 && <span>{msg("plan-tracker.added", "{count} added", { count: added })}</span>} {skipped > 0 && <span>{msg("plan-tracker.skipped", "{count} skipped", { count: skipped })}</span>} </p> )} <ol className="grid gap-1"> {steps.map((s, i) => { const S = STATUS[s.status] const over = s.estimate !== undefined && s.actual !== undefined && s.actual > s.estimate * 1.25 && s.actual - s.estimate >= 0.5 return ( <li key={s.id} data-status={s.status} className={cn("grid grid-cols-[1.5rem_1fr_auto] items-start gap-x-3 rounded-lg px-2 py-2", s.status === "doing" && "bg-accent/50",)}> <span aria-hidden="true" className={cn("mt-0.5 flex size-6 items-center justify-center rounded-full border text-[11px] tabular-nums", s.status === "done" && "border-foreground bg-foreground text-background", s.status === "doing" && "border-foreground", s.status === "blocked" && "border-destructive text-destructive", (s.status === "todo" || s.status === "skipped") && "text-muted-foreground")}> {s.status === "todo" ? i + 1 : <S.icon className={cn("size-3.5", s.status === "doing" && !paused && "animate-spin")} />} </span> <div className="min-w-0"> <p className={cn("text-sm leading-snug text-pretty", s.status === "skipped" && "text-muted-foreground line-through", s.status === "todo" && "text-foreground/80")}> <span className="sr-only">{msg(`plan-tracker.status.${s.status}`, S.label)}: </span> {s.title} {s.origin === "added" && <span className="ms-2 inline-flex items-center gap-1 rounded-full border px-1.5 py-px align-middle text-[10px] font-medium text-muted-foreground"><Plus aria-hidden="true" className="size-2.5" />{msg("plan-tracker.addedTag", "Added by the agent")}</span>} </p> {s.note && <p className={cn("mt-0.5 text-xs leading-snug text-pretty", s.status === "blocked" ? "font-medium text-destructive" : "text-muted-foreground")}>{s.note}</p>} {(s.status === "doing" || s.status === "blocked") && <p className="mt-1 text-[11px] font-medium text-foreground">{msg(`plan-tracker.status.${s.status}`, S.label)}</p>} </div> <p className="text-end text-xs tabular-nums text-muted-foreground"> {s.actual !== undefined && s.status !== "todo" && s.status !== "skipped" ? ( <> <span className={cn(over && "font-medium text-destructive")}>{formatMinutes(s.actual)}</span> {s.estimate !== undefined && <span> / {formatMinutes(s.estimate)}</span>} {over && s.estimate !== undefined && <span className="block font-medium text-destructive">{msg("plan-tracker.over", "over by {time}", { time: formatMinutes(s.actual - s.estimate) })}</span>} </> ) : s.estimate !== undefined && s.status !== "skipped" ? ( <span>{msg("plan-tracker.expected", "expected {time}", { time: formatMinutes(s.estimate) })}</span> ) : null} </p> </li> ) })} </ol> </section> ) } export { PlanTracker, formatMinutes, remainingMinutes, type PlanStatus, type PlanStep, type PlanTrackerProps }Update the import paths to match your project setup.
Usage
import { PlanTracker, formatMinutes, remainingMinutes } from "@/components/ballmac/plan-tracker"The full example is in the Code tab above.
Examples
A blocked step
Ship the fix
2 of 4 steps done · about 10 min left
Done: Run the full test suite
3.5 min / 4 min
Done: Deploy to staging
3 min / 3 min
Blocked: Deploy to production
Waiting for approval: production deploys need a person to confirm.
Blocked
under 1 min / 3 min
To do: Watch the error rate for ten minutes
expected 10 min
"use client"
import { PlanTracker } from "@/components/ballmac/plan-tracker"
export default function PlanTrackerBlocked() {
return (
<div className="w-full max-w-xl">
<PlanTracker
title="Ship the fix"
steps={[
{ id: "1", title: "Run the full test suite", status: "done", estimate: 4, actual: 3.5 },
{ id: "2", title: "Deploy to staging", status: "done", estimate: 3, actual: 3 },
{ id: "3", title: "Deploy to production", status: "blocked", estimate: 3, actual: 0.5, note: "Waiting for approval: production deploys need a person to confirm." },
{ id: "4", title: "Watch the error rate for ten minutes", status: "todo", estimate: 10 },
]}
/>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
steps* | PlanStep[] | — |
titleHeading. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| List | An ordered list in plan order; each item starts with its state in words ('Done: …') for screen readers |
| Progress | A progressbar named 'Plan' with a spoken value such as '2 of 5 steps done' |
| State | Never only colour: icons, words for In progress and Blocked, and a line-through for Skipped |
| Changes | Added steps carry an 'Added by the agent' tag and the header counts added and skipped steps |
Use with AI
steps is [{ id, title, status: todo | doing | done | skipped | blocked, origin?: planned | added, estimate?: minutes, actual?: minutes, note? }] (required); title. The header says how many of the steps that count are done (skipped ones do not count) and about how long is left. A step over its estimate by a quarter and half a minute says by how much. Exports formatMinutes and remainingMinutes. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Plan Tracker (@ballmac/plan-tracker) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Showing the plan an agent is following, and where it has got to
- Making plan changes (added or skipped steps) visible instead of silent
Not for
- A checklist a person ticks off; use checklist
- Who is doing what on a shared timeline; use agent-lanes
- Spans with exact timings; use trace-waterfall
Registry JSON: https://ui.ballmac.com/r/plan-tracker.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
Pairs well with
Agent Steering
Redirecting an agent while it works: say what to change and when (now, after this step or at the end), and watch each instruction go from queued to applied or dropped. Take back an instruction that has not applied yet.
Context Window
How full an agent's context is and what fills it: a stacked bar by part with tokens and share in a legend, room kept for the answer, a plain warning when nearly full or over, and a Compact button that says why when it cannot.
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.
Run Ledger
What an agent run cost, step by step: sortable time, tokens and money with each row's share, a total, the most expensive step marked, and an optional budget meter that warns in words.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.