{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "agent-plan",
  "title": "Agent Plan",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "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.",
  "dependencies": [
    "motion@^12",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/agent-plan.tsx",
      "content": "// Ballmac UI: Agent Plan. https://ui.ballmac.com/components/agent-plan\n\"use client\"\n\nimport * as React from \"react\"\nimport { AlertCircle, Check, ChevronRight, Circle, Loader2, MinusCircle, RotateCw } from \"lucide-react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype PlanStatus = \"pending\" | \"running\" | \"done\" | \"failed\" | \"skipped\"\n\ntype PlanStep = {\n  /** Unique id within the plan. */\n  id: string\n  /** What the agent does in this step. */\n  title: string\n  /** One line of context under the title. */\n  description?: string\n  /** Current state of the step. */\n  status: PlanStatus\n  /** How long it took or has taken, already formatted, such as \"4.2s\". */\n  duration?: string\n  /** Extra content shown when the step is expanded: output, logs, a diff. */\n  detail?: React.ReactNode\n  /** Why the step failed. Shown in words under the title. */\n  error?: string\n  /** Nested steps. */\n  steps?: PlanStep[]\n}\n\nconst STATUS_WORD: Record<PlanStatus, string> = {\n  pending: \"Pending\",\n  running: \"Running\",\n  done: \"Done\",\n  failed: \"Failed\",\n  skipped: \"Skipped\",\n}\n\n/** Every step, parents before their substeps. */\nfunction flatten(steps: PlanStep[]): PlanStep[] {\n  return steps.flatMap((s) => [s, ...(s.steps ? flatten(s.steps) : [])])\n}\n\ntype AgentPlanProps = Omit<React.ComponentProps<\"section\">, \"title\"> & {\n  /** The plan's steps, in order. Steps can nest one more level through `steps`. */\n  steps: PlanStep[]\n  /** Heading of the plan. */\n  title?: string\n  /** A sentence about the goal, under the heading. */\n  description?: string\n  /** Ids of expanded steps (controlled). */\n  expanded?: string[]\n  /** Called with the new list of expanded step ids. */\n  onExpandedChange?: (expanded: string[]) => void\n  /** Called when the retry button of a failed step is pressed. The button only shows when this is set. */\n  onRetry?: (step: PlanStep) => void\n  /** Content on the right of the heading, such as a Stop button. */\n  actions?: React.ReactNode\n}\n\nfunction StatusIcon({ status, reduce }: { status: PlanStatus; reduce: boolean | null }) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={cn(\n        \"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\",\n        status === \"done\" && \"border-transparent bg-foreground text-background\",\n        status === \"running\" && \"border-foreground\",\n        status === \"failed\" && \"border-destructive text-destructive\",\n        status === \"skipped\" && \"border-dashed text-muted-foreground\",\n        status === \"pending\" && \"text-muted-foreground\"\n      )}\n    >\n      {status === \"done\" && (\n        <motion.span\n          initial={reduce ? false : { scale: 0.4, opacity: 0 }}\n          animate={{ scale: 1, opacity: 1 }}\n          transition={{ type: \"spring\", stiffness: 420, damping: 22 }}\n          className=\"flex\"\n        >\n          <Check className=\"size-3.5\" strokeWidth={3} />\n        </motion.span>\n      )}\n      {status === \"running\" && <Loader2 className=\"size-3.5 animate-spin motion-reduce:animate-none\" />}\n      {status === \"failed\" && <AlertCircle className=\"size-3.5\" />}\n      {status === \"skipped\" && <MinusCircle className=\"size-3.5\" />}\n      {status === \"pending\" && <Circle className=\"size-1.5 fill-current\" />}\n    </span>\n  )\n}\n\nfunction AgentPlan({\n  steps,\n  title,\n  description,\n  expanded: expandedProp,\n  onExpandedChange,\n  onRetry,\n  actions,\n  className,\n  ...props\n}: AgentPlanProps) {\n  const msg = useMessages()\n  title ??= msg(\"agent-plan.title\", \"Plan\")\n  const reduce = useReducedMotion()\n  const baseId = React.useId()\n  const everyStep = React.useMemo(() => flatten(steps), [steps])\n  // Progress counts the units of work: steps without substeps.\n  const all = React.useMemo(() => everyStep.filter((s) => !s.steps || s.steps.length === 0), [everyStep])\n  const [internal, setInternal] = React.useState<string[]>(() =>\n    everyStep.filter((s) => s.detail && (s.status === \"running\" || s.status === \"failed\")).map((s) => s.id)\n  )\n  const expanded = expandedProp ?? internal\n  const total = all.length\n  const done = all.filter((s) => s.status === \"done\").length\n  const failed = all.filter((s) => s.status === \"failed\").length\n  const current = everyStep.find((s) => s.status === \"running\")\n  const finished = total > 0 && all.every((s) => s.status === \"done\" || s.status === \"skipped\")\n\n  function toggle(id: string) {\n    const next = expanded.includes(id) ? expanded.filter((x) => x !== id) : [...expanded, id]\n    setInternal(next)\n    onExpandedChange?.(next)\n  }\n\n  const summary = finished\n    ? \"Plan complete\"\n    : current\n      ? `Working on: ${current.title}`\n      : failed\n        ? `${failed} step${failed === 1 ? \"\" : \"s\"} failed`\n        : `${done} of ${total} steps done`\n\n  function renderStep(step: PlanStep, depth: number, isLast: boolean) {\n    const open = expanded.includes(step.id)\n    const expandable = !!step.detail\n    const panelId = `${baseId}-${step.id}`\n    const body = (\n      <>\n        <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n          <span className=\"flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5\">\n            <span\n              className={cn(\n                \"text-sm leading-6 font-medium\",\n                step.status === \"pending\" || step.status === \"skipped\" ? \"text-muted-foreground\" : \"text-foreground\",\n                step.status === \"skipped\" && \"line-through decoration-muted-foreground/60\"\n              )}\n            >\n              {step.title}\n            </span>\n            {(step.status === \"running\" || step.status === \"failed\") && (\n              <span\n                className={cn(\n                  \"rounded-full border px-1.5 py-px text-[11px] leading-4 font-medium text-foreground\",\n                  step.status === \"failed\" && \"border-destructive/40 bg-destructive/10\"\n                )}\n              >\n                {STATUS_WORD[step.status]}\n              </span>\n            )}\n            {step.duration && (\n              <span className=\"font-mono text-[11px] text-muted-foreground tabular-nums\">{step.duration}</span>\n            )}\n          </span>\n          {step.description && <span className=\"text-[13px] leading-5 text-muted-foreground\">{step.description}</span>}\n        </span>\n        {expandable && (\n          <ChevronRight\n            aria-hidden=\"true\"\n            className={cn(\n              \"mt-1 size-4 shrink-0 text-muted-foreground transition-transform duration-200 motion-reduce:transition-none\",\n              open ? \"rotate-90\" : \"rtl:rotate-180\"\n            )}\n          />\n        )}\n      </>\n    )\n    return (\n      <li key={step.id} data-status={step.status} className=\"relative flex gap-3 pb-4 last:pb-0\">\n        {!isLast && (\n          <span\n            aria-hidden=\"true\"\n            className={cn(\n              \"absolute top-6 bottom-0 start-3 -ms-px w-0.5 rounded-full\",\n              step.status === \"done\" ? \"bg-foreground/80\" : \"bg-border\"\n            )}\n          />\n        )}\n        <StatusIcon status={step.status} reduce={reduce} />\n        <div className=\"min-w-0 flex-1\">\n          {expandable ? (\n            <button\n              type=\"button\"\n              aria-expanded={open}\n              aria-controls={panelId}\n              onClick={() => toggle(step.id)}\n              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\"\n            >\n              <span className=\"sr-only\">{`${STATUS_WORD[step.status]}:`}</span>{\" \"}\n              {body}\n            </button>\n          ) : (\n            <div className=\"flex items-start gap-2\">\n              <span className=\"sr-only\">{`${STATUS_WORD[step.status]}:`}</span>{\" \"}\n              {body}\n            </div>\n          )}\n          {step.error && step.status === \"failed\" && (\n            <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\">\n              <AlertCircle aria-hidden=\"true\" className=\"size-3.5 shrink-0 text-destructive\" />\n              <span className=\"min-w-0 flex-1\">{step.error}</span>\n              {onRetry && (\n                <button\n                  type=\"button\"\n                  onClick={() => onRetry(step)}\n                  aria-label={msg(\"agent-plan.retry\", \"Retry {title}\", { title: step.title })}\n                  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\"\n                >\n                  <RotateCw aria-hidden=\"true\" className=\"size-3\" />\n                  {msg(\"agent-plan.retry\", \"Retry\")}\n                </button>\n              )}\n            </div>\n          )}\n          <AnimatePresence initial={false}>\n            {expandable && open && (\n              <motion.div\n                id={panelId}\n                key=\"detail\"\n                initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n                animate={reduce ? { opacity: 1 } : { height: \"auto\", opacity: 1 }}\n                exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n                transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}\n                className=\"overflow-hidden\"\n              >\n                <div className=\"mt-2 rounded-lg border bg-muted/40 px-3 py-2.5 text-[13px] leading-5 text-foreground\">\n                  {step.detail}\n                </div>\n              </motion.div>\n            )}\n          </AnimatePresence>\n          {step.steps && step.steps.length > 0 && (\n            <ol className=\"mt-3 list-none\">{step.steps.map((s, i) => renderStep(s, depth + 1, i === step.steps!.length - 1))}</ol>\n          )}\n        </div>\n      </li>\n    )\n  }\n\n  return (\n    <section\n      data-slot=\"agent-plan\"\n      aria-label={title}\n      className={cn(\"w-full rounded-xl border bg-card p-4 text-card-foreground sm:p-5\", className)}\n      {...props}\n    >\n      <header className=\"mb-4 flex flex-wrap items-start justify-between gap-3\">\n        <div className=\"min-w-0\">\n          <h3 className=\"text-sm font-semibold text-foreground\">{title}</h3>\n          {description && <p className=\"mt-0.5 text-[13px] leading-5 text-muted-foreground\">{description}</p>}\n        </div>\n        {actions}\n      </header>\n      <div className=\"mb-4 flex items-center gap-3\">\n        <div\n          role=\"progressbar\"\n          aria-label={msg(\"agent-plan.progress\", \"{title} progress\", { title })}\n          aria-valuemin={0}\n          aria-valuemax={total}\n          aria-valuenow={done}\n          aria-valuetext={`${done} of ${total} steps done`}\n          className=\"flex h-1.5 flex-1 gap-0.5 overflow-hidden rounded-full\"\n        >\n          {all.map((s) => (\n            <span\n              key={s.id}\n              aria-hidden=\"true\"\n              className={cn(\n                \"h-full flex-1 rounded-full transition-colors duration-300 motion-reduce:transition-none\",\n                s.status === \"done\" && \"bg-foreground\",\n                s.status === \"running\" && \"animate-pulse bg-foreground/50 motion-reduce:animate-none\",\n                s.status === \"failed\" && \"bg-destructive\",\n                (s.status === \"pending\" || s.status === \"skipped\") && \"bg-muted\"\n              )}\n            />\n          ))}\n        </div>\n        <span className=\"font-mono text-xs text-muted-foreground tabular-nums\">\n          {done}/{total}\n        </span>\n      </div>\n      <ol className=\"list-none\">{steps.map((s, i) => renderStep(s, 0, i === steps.length - 1))}</ol>\n      <p className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {summary}\n      </p>\n    </section>\n  )\n}\n\nexport { AgentPlan, type AgentPlanProps, type PlanStep, type PlanStatus }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/agent-plan.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "agent",
      "plan",
      "steps",
      "timeline",
      "tasks"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "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.",
      "whenToUse": [
        "Showing what an agent will do and is doing",
        "Multi-step workflows that can fail part-way"
      ],
      "whenNotToUse": [
        "A single tool invocation; use tool-call-card",
        "Onboarding checklists; use onboarding-checklist"
      ],
      "composesWith": [
        "tool-call-card",
        "approval-card",
        "thinking-indicator"
      ],
      "a11y": [
        {
          "keys": "Enter / Space",
          "action": "Expands or collapses a step's output"
        },
        {
          "keys": "Screen readers",
          "action": "Each step reads its status first; a polite status line announces the current step"
        },
        {
          "keys": "Color",
          "action": "Every state has an icon and, for running and failed, a word"
        }
      ],
      "customization": [
        "expanded / onExpandedChange",
        "onRetry",
        "actions slot",
        "nested steps"
      ]
    },
    "examples": [
      "agent-plan-demo",
      "agent-plan-failed"
    ],
    "url": "https://ui.ballmac.com/components/agent-plan"
  },
  "categories": [
    "ai"
  ],
  "type": "registry:ui"
}