{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "agent-plan",
  "type": "registry:ui",
  "title": "Agent Plan",
  "description": "A live checklist the agent rewrites mid-run, with edits that grow in place.",
  "categories": [
    "ai-agents"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/agent-plan.tsx",
      "content": "\"use client\";\nimport { Collapsible } from \"@base-ui/react/collapsible\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type PlanStepStatus = \"pending\" | \"active\" | \"done\" | \"failed\" | \"skipped\";\n\nexport type PlanStep = {\n  /** Stable across edits, so a renamed step crossfades instead of being replaced. */\n  id: string;\n  label: React.ReactNode;\n  status: PlanStepStatus;\n  /** One line under the label: progress while active, the reason when failed. */\n  detail?: React.ReactNode;\n  substeps?: PlanStep[];\n};\n\nexport type AgentPlanProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"title\"> & {\n  steps: PlanStep[];\n  /** What the plan is for. Defaults to “Plan”. */\n  title?: React.ReactNode;\n  /** The whole plan collapses to its header, which keeps showing the current step. */\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n};\n\nconst spoken: Record<PlanStepStatus, string> = { pending: \"to do\", active: \"in progress\", done: \"done\", failed: \"failed\", skipped: \"skipped\" };\nconst text = (n: React.ReactNode) => (typeof n === \"string\" || typeof n === \"number\" ? String(n) : \"\");\n\n/**\n * A checklist the agent keeps up to date. Pass the whole plan on every change;\n * steps are matched by id, so additions grow in, removals fold away and\n * renamed steps crossfade, while everything else stays put.\n */\nexport function AgentPlan({ steps, title = \"Plan\", open, defaultOpen = true, onOpenChange, className, ...rest }: AgentPlanProps) {\n  const reduce = useReducedMotion();\n  const total = steps.length;\n  const done = steps.filter((s) => s.status === \"done\" || s.status === \"skipped\").length;\n  const activeIndex = steps.findIndex((s) => s.status === \"active\");\n  const failedIndex = steps.findIndex((s) => s.status === \"failed\");\n  const current = activeIndex >= 0 ? steps[activeIndex] : failedIndex >= 0 ? steps[failedIndex] : undefined;\n  const complete = total > 0 && done === total;\n\n  // Derived, so it changes exactly when something worth hearing does.\n  const announcement = complete\n    ? `All ${total} steps done`\n    : current\n      ? `Step ${steps.indexOf(current) + 1} of ${total} ${current.status === \"failed\" ? \"failed\" : \"in progress\"}${text(current.label) ? `: ${text(current.label)}` : \"\"}`\n      : \"\";\n\n  return (\n    <Collapsible.Root\n      open={open}\n      defaultOpen={defaultOpen}\n      onOpenChange={(next) => onOpenChange?.(next)}\n      data-state={complete ? \"complete\" : current?.status === \"failed\" ? \"failed\" : current ? \"active\" : \"idle\"}\n      className={cn(\"group/plan min-w-0 rounded-xl border border-line bg-raised shadow-[var(--shadow)]\", className)}\n      {...rest}\n    >\n      <Collapsible.Trigger\n        className={cn(\n          \"group/trigger relative flex h-11 w-full min-w-0 select-none items-center gap-2.5 rounded-[11px] pl-3 pr-3.5 text-left\",\n          \"touch-manipulation [-webkit-tap-highlight-color:transparent]\",\n          \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n          \"transition-[background-color] duration-150 hover:bg-hover active:bg-fg/[0.06] active:duration-75 data-[panel-open]:rounded-b-none\",\n        )}\n      >\n        <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className=\"shrink-0 text-fg-3 transition-[rotate,color] duration-[240ms] ease-in-out-quart group-hover/trigger:text-fg-2 group-data-[panel-open]/trigger:rotate-90 motion-reduce:transition-none\">\n          <path d=\"m6.25 4.5 3.5 3.5-3.5 3.5\" />\n        </svg>\n        <span className=\"shrink-0 text-[13px] font-medium tracking-[-0.006em] text-fg\">{title}</span>\n\n        {/* Closed, the header still says what is happening now. */}\n        <span className=\"relative grid min-w-0 flex-1 overflow-hidden transition-opacity duration-200 group-data-[panel-open]/trigger:opacity-0\">\n          <AnimatePresence initial={false}>\n            {current && (\n              <motion.span\n                key={current.id + text(current.label)}\n                aria-hidden\n                className={cn(\n                  \"col-start-1 row-start-1 truncate text-[12.5px]\",\n                  current.status === \"failed\" ? \"text-danger\" : \"text-fg-2\",\n                )}\n                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" }}\n                animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.in } }}\n                transition={{ duration: reduce ? 0.15 : 0.24, ease: ease.out }}\n              >\n                <Sheen active={current.status === \"active\"}>{current.label}</Sheen>\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </span>\n\n        <span className=\"flex shrink-0 items-center gap-2.5\">\n          <span className={cn(\"text-[12px] tabular\", complete ? \"text-fg-2\" : \"text-fg-3\")}>\n            <NumberFlow value={done} />\n            <span className=\"text-fg-4\">/</span>\n            <NumberFlow value={total} />\n          </span>\n          <span aria-hidden className=\"relative h-1 w-10 overflow-hidden rounded-full bg-line-2 max-[360px]:hidden\">\n            <span\n              className={cn(\"absolute inset-0 origin-left rounded-full transition-[scale,background-color] duration-500 ease-in-out-quart motion-reduce:transition-none\", complete ? \"bg-success\" : \"bg-fg-2\")}\n              style={{ scale: `${total ? done / total : 0} 1` }}\n            />\n          </span>\n        </span>\n        <span className=\"sr-only\">\n          , {done} of {total} done\n        </span>\n      </Collapsible.Trigger>\n\n      <Collapsible.Panel\n        className={cn(\n          \"group/panel h-(--collapsible-panel-height) overflow-hidden\",\n          \"transition-[height] duration-[260ms] ease-out-quart data-ending-style:duration-200 data-ending-style:ease-in-out-quart\",\n          \"data-starting-style:h-0 data-ending-style:h-0 motion-reduce:transition-none\",\n        )}\n      >\n        <div\n          className={cn(\n            \"border-t border-line px-1.5 pb-2 pt-1.5\",\n            \"transition-[opacity,translate] delay-[50ms] duration-[280ms] ease-out-expo\",\n            \"group-data-[starting-style]/panel:-translate-y-1 group-data-[starting-style]/panel:opacity-0\",\n            \"group-data-[ending-style]/panel:opacity-0 group-data-[ending-style]/panel:delay-0 group-data-[ending-style]/panel:duration-[140ms]\",\n            \"motion-reduce:translate-y-0 motion-reduce:delay-0\",\n          )}\n        >\n          {total === 0 ? (\n            <p className=\"px-2 py-2 text-[12.5px] text-fg-3\">\n              <Sheen active>Drafting a plan</Sheen>\n            </p>\n          ) : (\n            <StepList steps={steps} />\n          )}\n        </div>\n      </Collapsible.Panel>\n\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n        {announcement}\n      </span>\n    </Collapsible.Root>\n  );\n}\n\nfunction StepList({ steps, depth = 0 }: { steps: PlanStep[]; depth?: number }) {\n  return (\n    <ol className=\"flex flex-col\">\n      <AnimatePresence initial={false}>\n        {steps.map((step) => (\n          <Step key={step.id} step={step} depth={depth} />\n        ))}\n      </AnimatePresence>\n    </ol>\n  );\n}\n\nfunction Step({ step, depth }: { step: PlanStep; depth: number }) {\n  const reduce = useReducedMotion();\n  const { status, label, detail, substeps } = step;\n  const hasSubs = !!substeps?.length;\n  // Substeps follow the work (open while it's here, folded once it's behind)\n  // until someone opens or closes them by hand; then their choice sticks.\n  const [override, setOverride] = useState<boolean>();\n  const auto = status === \"active\" || status === \"failed\";\n  const expanded = hasSubs && (override ?? auto);\n  const subsDone = substeps?.filter((s) => s.status === \"done\" || s.status === \"skipped\").length ?? 0;\n  const sub = depth > 0;\n  const labelKey = text(label) || step.id;\n\n  const row = (\n    <>\n      <span className={cn(\"relative grid shrink-0 place-items-center\", sub ? \"mt-[3px] size-3.5\" : \"mt-0.5 size-4\")}>\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={status}\n            className=\"absolute inset-0 grid place-items-center\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, scale: 0.8, transition: { duration: 0.12, ease: ease.in } }}\n            transition={reduce ? { duration: 0.15 } : spring.pop}\n          >\n            <Node status={status} small={sub} reduce={!!reduce} />\n          </motion.span>\n        </AnimatePresence>\n      </span>\n\n      <span className=\"flex min-w-0 flex-1 flex-col\">\n        <span className=\"grid min-w-0\">\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={labelKey}\n              className={cn(\n                \"col-start-1 row-start-1 min-w-0 text-pretty transition-colors duration-300\",\n                sub ? \"text-[12.5px] leading-5\" : \"text-[13px] leading-5 tracking-[-0.003em]\",\n                status === \"active\" || status === \"failed\" ? \"text-fg\" : status === \"done\" ? \"text-fg-3\" : status === \"skipped\" ? \"text-fg-4 line-through decoration-fg-4\" : \"text-fg-2\",\n              )}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 5, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -5, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.in } }}\n              transition={{ duration: reduce ? 0.15 : 0.26, ease: ease.out }}\n            >\n              <Sheen active={status === \"active\"}>{label}</Sheen>\n            </motion.span>\n          </AnimatePresence>\n        </span>\n        <AnimatePresence initial={false}>\n          {detail != null && (status === \"active\" || status === \"failed\") && (\n            <motion.span\n              key=\"detail\"\n              className=\"overflow-hidden\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n              animate={{ opacity: 1, height: \"auto\" }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0, transition: { duration: 0.16, ease: ease.in } }}\n              transition={{ duration: reduce ? 0.15 : 0.24, ease: ease.out }}\n            >\n              <span className=\"grid\">\n                <AnimatePresence initial={false}>\n                  <motion.span\n                    key={text(detail) || \"d\"}\n                    className={cn(\"col-start-1 row-start-1 text-pretty text-[12px] leading-[18px]\", status === \"failed\" ? \"text-danger\" : \"text-fg-3\")}\n                    initial={{ opacity: 0 }}\n                    animate={{ opacity: 1 }}\n                    exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                    transition={{ duration: 0.2 }}\n                  >\n                    {detail}\n                  </motion.span>\n                </AnimatePresence>\n              </span>\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </span>\n\n      {hasSubs && (\n        <span className=\"mt-0.5 flex shrink-0 items-center gap-1 text-[11.5px] tabular text-fg-3\">\n          {subsDone}/{substeps!.length}\n          <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className={cn(\"text-fg-4 transition-[rotate,color] duration-[240ms] ease-in-out-quart group-hover/row:text-fg-2 motion-reduce:transition-none\", expanded && \"rotate-180\")}>\n            <path d=\"m4.5 6.25 3.5 3.5 3.5-3.5\" />\n          </svg>\n        </span>\n      )}\n      <span className=\"sr-only\">, {spoken[status]}</span>\n    </>\n  );\n\n  const rowClass = cn(\"group/row relative flex w-full min-w-0 items-start gap-2.5 rounded-md px-2 text-left\", sub ? \"py-[3px]\" : \"py-1.5\");\n\n  return (\n    <motion.li\n      data-status={status}\n      aria-current={status === \"active\" ? \"step\" : undefined}\n      className=\"relative\"\n      initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n      animate={{ opacity: 1, height: \"auto\" }}\n      exit={reduce ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, height: 0, x: -4, transition: { duration: 0.2, ease: ease.in } }}\n      transition={{ duration: reduce ? 0.15 : 0.28, ease: ease.out }}\n    >\n      {/* A step the agent added mid-run keeps a soft wash for a moment so the edit can be found. */}\n      <motion.span\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 rounded-md bg-fg/[0.06]\"\n        initial={{ opacity: 1 }}\n        animate={{ opacity: 0 }}\n        transition={{ duration: 0.8, delay: 1, ease: ease.outQuart }}\n      />\n      {hasSubs ? (\n        <button\n          type=\"button\"\n          aria-expanded={expanded}\n          onClick={() => setOverride(!expanded)}\n          className={cn(\n            rowClass,\n            \"touch-manipulation outline-none transition-[background-color] duration-150 hover:bg-hover active:bg-fg/[0.06] active:duration-75\",\n            \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n          )}\n        >\n          {row}\n        </button>\n      ) : (\n        <div className={rowClass}>{row}</div>\n      )}\n\n      <AnimatePresence initial={false}>\n        {expanded && (\n          <motion.div\n            key=\"subs\"\n            className=\"overflow-hidden\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n            animate={{ opacity: 1, height: \"auto\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0, transition: { duration: 0.2, ease: ease.inOut } }}\n            transition={{ duration: reduce ? 0.15 : 0.28, ease: ease.out }}\n          >\n            <div className=\"pb-1 pl-[26px]\">\n              <StepList steps={substeps!} depth={depth + 1} />\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </motion.li>\n  );\n}\n\nconst glyph = { viewBox: \"0 0 16 16\", fill: \"none\", stroke: \"currentColor\", strokeWidth: 2, strokeLinecap: \"round\" as const, strokeLinejoin: \"round\" as const, \"aria-hidden\": true };\n\nfunction Node({ status, small, reduce }: { status: PlanStepStatus; small: boolean; reduce: boolean }) {\n  // Identical attributes with and without reduced motion, so server and client agree.\n  const draw = (delay = 0.08) => ({\n    initial: { pathLength: reduce ? 1 : 0 },\n    animate: { pathLength: 1 },\n    transition: reduce ? { duration: 0 } : { duration: 0.3, ease: ease.out, delay },\n  });\n  const size = small ? \"size-3.5\" : \"size-4\";\n  if (status === \"done\")\n    return (\n      <span className={cn(\"grid place-items-center rounded-full bg-fg text-frame\", size)}>\n        <svg {...glyph} className={small ? \"size-2.5\" : \"size-3\"}>\n          <motion.path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" {...draw()} />\n        </svg>\n      </span>\n    );\n  if (status === \"failed\")\n    return (\n      <span className={cn(\"grid place-items-center rounded-full bg-danger text-frame\", size)}>\n        <svg {...glyph} className={small ? \"size-2.5\" : \"size-3\"}>\n          <motion.path d=\"m5 5 6 6\" {...draw()} />\n          <motion.path d=\"m11 5-6 6\" {...draw(0.16)} />\n        </svg>\n      </span>\n    );\n  if (status === \"active\")\n    return (\n      <span className={cn(\"relative grid place-items-center\", size)}>\n        <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden className={cn(\"absolute inset-0 animate-spin [animation-duration:0.9s]\", size)}>\n          <circle cx=\"8\" cy=\"8\" r=\"6.75\" stroke=\"var(--line-2)\" strokeWidth=\"1.5\" />\n          <path d=\"M8 1.25A6.75 6.75 0 0 1 14.75 8\" stroke=\"var(--fg)\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n        </svg>\n      </span>\n    );\n  if (status === \"skipped\") return <span className={cn(\"rounded-full border border-dashed border-fg-4\", size)} />;\n  return <span className={cn(\"rounded-full border-[1.5px] border-line-2\", small ? \"size-3\" : \"size-3.5\")} />;\n}\n\n// The sheen marks work in progress and nothing else. Reduced motion keeps the plain label.\nfunction Sheen({ active, children }: { active: boolean; children: React.ReactNode }) {\n  if (!active) return <>{children}</>;\n  return (\n    <span\n      className={cn(\n        \"bg-[linear-gradient(90deg,var(--fg-2)_0%,var(--fg-2)_40%,var(--fg)_50%,var(--fg-2)_60%,var(--fg-2)_100%)]\",\n        \"animate-shine bg-clip-text [background-size:250%_100%] [-webkit-box-decoration-break:clone] [box-decoration-break:clone] [-webkit-text-fill-color:transparent] rtl:[animation-direction:reverse]\",\n        \"motion-reduce:animate-none motion-reduce:bg-none motion-reduce:[-webkit-text-fill-color:currentColor]\",\n      )}\n    >\n      {children}\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/agent-plan"
}
