{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "plan-review",
  "type": "registry:component",
  "title": "Plan Review",
  "description": "Look over an agent's plan before it starts, rename, reorder or drop steps, then follow each one on its own timer and pick up again from the step that broke.",
  "categories": [
    "component",
    "Trust & Control"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/button.json"
  ],
  "files": [
    {
      "path": "registry/devl/plan-review.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { CheckIcon, GripVerticalIcon, PencilIcon, PlayIcon, PlusIcon, RotateCcwIcon, SquareIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, Reorder, useDragControls, useReducedMotion } from \"motion/react\";\nimport { type KeyboardEvent, useEffect, useRef, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface PlanStep {\n  id: string;\n  label: string;\n  tool?: string;\n  duration?: number;\n  failOnce?: boolean;\n  error?: string;\n}\n\nexport interface PlanReviewProps {\n  steps: PlanStep[];\n  title?: string;\n  agent?: string;\n  onRun?: (steps: PlanStep[]) => void;\n  className?: string;\n}\n\ntype Phase = \"review\" | \"running\" | \"failed\" | \"done\";\ntype StepStatus = \"pending\" | \"running\" | \"done\" | \"failed\";\n\nlet nextId = 0;\n\nconst press = \"transition-[scale,box-shadow,background-color] duration-150 ease-out active:scale-[0.97]\";\n\nfunction secs(ms: number) {\n  return `${(ms / 1000).toFixed(1)}s`;\n}\n\nexport function PlanReview({ steps: initial, title = \"Plan\", agent = \"Agent\", onRun, className }: PlanReviewProps) {\n  const reduce = useReducedMotion();\n  const [steps, setSteps] = useState(initial);\n  const [phase, setPhase] = useState<Phase>(\"review\");\n  const [cursor, setCursor] = useState(0);\n  const [status, setStatus] = useState<Record<string, StepStatus>>({});\n  const [times, setTimes] = useState<Record<string, number>>({});\n  const [editing, setEditing] = useState<string | null>(null);\n  const failedBefore = useRef(new Set<string>());\n\n  useEffect(() => {\n    if (phase !== \"running\") return;\n    const step = steps[cursor];\n    if (!step) return;\n    const duration = step.duration ?? 1800;\n    const started = performance.now();\n    setStatus((s) => ({ ...s, [step.id]: \"running\" }));\n    const timer = setInterval(() => {\n      const ms = performance.now() - started;\n      const willFail = step.failOnce && !failedBefore.current.has(step.id);\n      const end = willFail ? duration * 0.6 : duration;\n      if (ms < end) {\n        setTimes((t) => ({ ...t, [step.id]: ms }));\n        return;\n      }\n      clearInterval(timer);\n      setTimes((t) => ({ ...t, [step.id]: end }));\n      if (willFail) {\n        failedBefore.current.add(step.id);\n        setStatus((s) => ({ ...s, [step.id]: \"failed\" }));\n        setPhase(\"failed\");\n        return;\n      }\n      setStatus((s) => ({ ...s, [step.id]: \"done\" }));\n      if (cursor + 1 >= steps.length) setPhase(\"done\");\n      else setCursor(cursor + 1);\n    }, 50);\n    return () => clearInterval(timer);\n  }, [phase, cursor, steps]);\n\n  const run = () => {\n    setEditing(null);\n    setStatus({});\n    setTimes({});\n    failedBefore.current.clear();\n    setCursor(0);\n    setPhase(\"running\");\n    onRun?.(steps);\n  };\n\n  const retryFrom = (index: number) => {\n    const rest = steps.slice(index).map((s) => s.id);\n    setStatus((s) => Object.fromEntries(Object.entries(s).filter(([id]) => !rest.includes(id))));\n    setTimes((t) => Object.fromEntries(Object.entries(t).filter(([id]) => !rest.includes(id))));\n    setCursor(index);\n    setPhase(\"running\");\n  };\n\n  const backToReview = () => {\n    setPhase(\"review\");\n    setStatus({});\n    setTimes({});\n    failedBefore.current.clear();\n  };\n\n  const move = (index: number, by: number) => {\n    const to = index + by;\n    if (to < 0 || to >= steps.length) return;\n    setSteps((s) => {\n      const next = [...s];\n      const [item] = next.splice(index, 1);\n      next.splice(to, 0, item);\n      return next;\n    });\n  };\n\n  const add = () => {\n    const id = `new-${++nextId}`;\n    setSteps((s) => [...s, { id, label: \"\", duration: 1800 }]);\n    setEditing(id);\n  };\n\n  const rename = (id: string, label: string) => {\n    setEditing(null);\n    setSteps((s) => {\n      const trimmed = label.trim();\n      if (!trimmed) return s.filter((x) => x.id !== id || x.label);\n      return s.map((x) => (x.id === id ? { ...x, label: trimmed } : x));\n    });\n  };\n\n  const doneCount = steps.filter((s) => status[s.id] === \"done\").length;\n  const total = Object.values(times).reduce((a, b) => a + b, 0);\n  const estimate = steps.reduce((a, s) => a + (s.duration ?? 1800), 0);\n  const failedIndex = steps.findIndex((s) => status[s.id] === \"failed\");\n  const editable = phase === \"review\";\n  const spring = reduce ? { duration: 0 } : { type: \"spring\" as const, stiffness: 420, damping: 34 };\n\n  return (\n    <div className={cn(\"w-full max-w-[440px] rounded-2xl border bg-card text-card-foreground shadow-xs/5\", className)}>\n      <div className=\"px-4 pt-4\">\n        <div className=\"flex items-baseline justify-between gap-3\">\n          <h3 className=\"font-medium text-sm\">{title}</h3>\n          <span className=\"text-muted-foreground text-xs tabular-nums\">\n            {phase === \"review\" && `${steps.length} steps · ~${Math.round(estimate / 1000)}s`}\n            {phase === \"running\" && `Step ${cursor + 1} of ${steps.length} · ${secs(total)}`}\n            {phase === \"failed\" && `Stopped at step ${failedIndex + 1}`}\n            {phase === \"done\" && `Done in ${secs(total)}`}\n          </span>\n        </div>\n        <p className=\"min-h-8 text-pretty text-muted-foreground text-xs sm:min-h-4\">\n          {phase === \"review\" && `${agent} runs these top to bottom. Edit them before you start.`}\n          {phase === \"running\" && `${agent} is working through the plan.`}\n          {phase === \"failed\" && `${agent} stopped. Retry from the failed step, or edit the plan.`}\n          {phase === \"done\" && `${agent} finished every step.`}\n        </p>\n        <div\n          aria-label=\"Steps done\"\n          aria-valuemax={steps.length}\n          aria-valuemin={0}\n          aria-valuenow={doneCount}\n          className=\"mt-3 h-1 overflow-hidden rounded-full bg-muted\"\n          role=\"progressbar\"\n        >\n          <motion.div\n            animate={{ x: `${(steps.length ? doneCount / steps.length : 0) * 100 - 100}%` }}\n            className={cn(\n              \"h-full rounded-full transition-colors duration-200\",\n              phase === \"failed\" ? \"bg-destructive\" : \"bg-primary\",\n            )}\n            initial={false}\n            transition={spring}\n          />\n        </div>\n      </div>\n\n      <Reorder.Group\n        as=\"ol\"\n        axis=\"y\"\n        className=\"relative flex flex-col p-2\"\n        onReorder={setSteps}\n        values={steps}\n      >\n        {steps.length === 0 && (\n          <li className=\"flex flex-col items-center gap-1 px-6 py-8 text-center\">\n            <span className=\"font-medium text-sm\">No steps yet</span>\n            <span className=\"text-pretty text-muted-foreground text-xs\">Add a step to tell {agent} what to do first.</span>\n          </li>\n        )}\n        <AnimatePresence initial={false}>\n          {steps.map((step, index) => (\n            <PlanRow\n              editable={editable}\n              editing={editing === step.id}\n              index={index}\n              isLast={index === steps.length - 1}\n              key={step.id}\n              ms={times[step.id]}\n              onEdit={() => setEditing(step.id)}\n              onMove={(by) => move(index, by)}\n              onRemove={() => setSteps((s) => s.filter((x) => x.id !== step.id))}\n              onRename={(label) => rename(step.id, label)}\n              onRetry={() => retryFrom(index)}\n              reduce={!!reduce}\n              status={status[step.id] ?? \"pending\"}\n              step={step}\n            />\n          ))}\n        </AnimatePresence>\n      </Reorder.Group>\n\n      <div className=\"flex items-center justify-between gap-2 border-t px-3 py-2.5\">\n        {editable ? (\n          <Button className={press} onClick={add} size=\"sm\" variant=\"ghost\">\n            <PlusIcon />\n            Add step\n          </Button>\n        ) : (\n          <Button className={press} onClick={backToReview} size=\"sm\" variant=\"ghost\">\n            {phase === \"running\" ? <SquareIcon /> : <PencilIcon />}\n            {phase === \"running\" ? \"Stop\" : \"Edit plan\"}\n          </Button>\n        )}\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {(phase === \"review\" || phase === \"done\") && (\n            <motion.span\n              animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n              className=\"origin-right\"\n              exit={{ opacity: 0, scale: reduce ? 1 : 0.95, filter: \"blur(2px)\" }}\n              initial={{ opacity: 0, scale: reduce ? 1 : 0.95, filter: \"blur(2px)\" }}\n              key={phase}\n              transition={{ duration: 0.18, ease: [0.23, 1, 0.32, 1] }}\n            >\n              <Button className={press} disabled={!steps.length} onClick={run} size=\"sm\">\n                {phase === \"done\" ? <RotateCcwIcon /> : <PlayIcon />}\n                {phase === \"done\" ? \"Run again\" : \"Run plan\"}\n              </Button>\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n}\n\nfunction PlanRow({\n  step,\n  index,\n  isLast,\n  status,\n  ms,\n  editable,\n  editing,\n  onEdit,\n  onRename,\n  onRemove,\n  onMove,\n  onRetry,\n  reduce,\n}: {\n  step: PlanStep;\n  index: number;\n  isLast: boolean;\n  status: StepStatus;\n  ms?: number;\n  editable: boolean;\n  editing: boolean;\n  onEdit: () => void;\n  onRename: (label: string) => void;\n  onRemove: () => void;\n  onMove: (by: number) => void;\n  onRetry: () => void;\n  reduce: boolean;\n}) {\n  const controls = useDragControls();\n  const [draft, setDraft] = useState(step.label);\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  useEffect(() => {\n    if (editing) {\n      setDraft(step.label);\n      requestAnimationFrame(() => inputRef.current?.select());\n    }\n  }, [editing, step.label]);\n\n  const onHandleKey = (e: KeyboardEvent) => {\n    if (e.key === \"ArrowUp\" || e.key === \"ArrowDown\") {\n      e.preventDefault();\n      onMove(e.key === \"ArrowUp\" ? -1 : 1);\n    }\n  };\n\n  return (\n    <Reorder.Item\n      animate={{ opacity: 1, height: \"auto\" }}\n      as=\"li\"\n      className=\"relative list-none rounded-lg bg-card\"\n      dragControls={controls}\n      dragListener={false}\n      exit={{ opacity: 0, height: 0 }}\n      initial={{ opacity: 0, height: 0 }}\n      transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 420, damping: 36 }}\n      value={step}\n      whileDrag={{ scale: 1.02, boxShadow: \"0 8px 24px -12px rgb(0 0 0 / 0.25)\", zIndex: 10 }}\n    >\n      <div className=\"group/row flex items-start gap-2.5 rounded-lg px-1.5 py-1.5 transition-colors duration-150 hover:bg-muted/50\">\n        {editable ? (\n          <button\n            aria-label={`Move step ${index + 1}. Drag, or press up and down arrows.`}\n            className=\"relative mt-1 flex h-6 w-4 shrink-0 cursor-grab touch-none items-center justify-center rounded text-muted-foreground/60 outline-none transition-colors duration-150 after:absolute after:-inset-x-2 after:-inset-y-1 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing\"\n            onKeyDown={onHandleKey}\n            onPointerDown={(e) => controls.start(e)}\n            type=\"button\"\n          >\n            <GripVerticalIcon className=\"size-3.5\" />\n          </button>\n        ) : (\n          <span className=\"w-4 shrink-0\" />\n        )}\n\n        <span className=\"relative flex flex-col items-center self-stretch\">\n          <StepDot index={index} reduce={reduce} status={status} />\n          {!isLast && (\n            <span className=\"relative mt-1 w-px flex-1 overflow-hidden bg-border\">\n              <motion.span\n                animate={{ scaleY: status === \"done\" ? 1 : 0 }}\n                className=\"absolute inset-0 origin-top bg-primary\"\n                initial={false}\n                transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 200, damping: 30 }}\n              />\n            </span>\n          )}\n        </span>\n\n        <div className=\"min-w-0 flex-1 pb-1.5\">\n          {editing ? (\n            <input\n              aria-label={`Step ${index + 1}`}\n              className=\"h-7 w-full rounded-lg border border-input bg-background px-2 text-sm outline-none ring-ring/24 focus-visible:border-ring focus-visible:ring-[3px]\"\n              onBlur={() => onRename(draft)}\n              onChange={(e) => setDraft(e.target.value)}\n              onKeyDown={(e) => {\n                if (e.key === \"Enter\") onRename(draft);\n                if (e.key === \"Escape\") onRename(step.label);\n              }}\n              placeholder=\"Describe the step\"\n              ref={inputRef}\n              value={draft}\n            />\n          ) : editable ? (\n            <button\n              className=\"block w-full cursor-text rounded-md pt-1 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n              onClick={onEdit}\n              title=\"Click to rename\"\n              type=\"button\"\n            >\n              {step.label}\n            </button>\n          ) : (\n            <p\n              className={cn(\n                \"pt-1 text-sm transition-colors\",\n                status === \"pending\" && \"text-muted-foreground\",\n                status === \"failed\" && \"text-destructive-foreground\",\n              )}\n            >\n              {step.label}\n            </p>\n          )}\n          {step.tool && !editing && (\n            <span className=\"block font-mono text-[11px] text-muted-foreground leading-5\">{step.tool}</span>\n          )}\n          <AnimatePresence initial={false}>\n            {status === \"failed\" && (\n              <motion.div\n                animate={{ opacity: 1, height: \"auto\" }}\n                className=\"overflow-hidden\"\n                exit={{ opacity: 0, height: 0 }}\n                initial={{ opacity: 0, height: 0 }}\n                transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 380, damping: 32 }}\n              >\n                <div className=\"mt-2 rounded-lg bg-destructive/8 px-2.5 py-2 dark:bg-destructive/16\">\n                  <p className=\"font-mono text-destructive-foreground text-xs\">{step.error ?? \"Step failed\"}</p>\n                  <Button className={cn(\"mt-2\", press)} onClick={onRetry} size=\"xs\" variant=\"outline\">\n                    <RotateCcwIcon />\n                    Retry from here\n                  </Button>\n                </div>\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </div>\n\n        <span className=\"mt-1 flex h-6 shrink-0 items-center\">\n          {editable ? (\n            <button\n              aria-label={`Remove step ${index + 1}`}\n              className=\"relative flex size-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-0 outline-none transition-[opacity,background-color,color] duration-150 after:absolute after:-inset-1 hover:bg-accent hover:text-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring group-hover/row:opacity-100 pointer-coarse:opacity-100\"\n              onClick={onRemove}\n              type=\"button\"\n            >\n              <XIcon className=\"size-3.5\" />\n            </button>\n          ) : (\n            ms !== undefined && (\n              <span\n                className={cn(\n                  \"font-mono text-xs tabular-nums\",\n                  status === \"running\" ? \"text-foreground\" : \"text-muted-foreground\",\n                )}\n              >\n                {secs(ms)}\n              </span>\n            )\n          )}\n        </span>\n      </div>\n    </Reorder.Item>\n  );\n}\n\nfunction StepDot({ index, status, reduce }: { index: number; status: StepStatus; reduce: boolean }) {\n  return (\n    <span\n      className={cn(\n        \"relative mt-1 flex size-6 shrink-0 items-center justify-center rounded-full border font-medium text-[11px] tabular-nums transition-colors duration-200\",\n        status === \"pending\" && \"border-border bg-background text-muted-foreground\",\n        status === \"running\" && \"border-info/40 bg-info/10 text-info-foreground\",\n        status === \"done\" && \"border-primary bg-primary text-primary-foreground\",\n        status === \"failed\" && \"border-destructive bg-destructive text-white\",\n      )}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          animate={{ scale: 1, opacity: 1 }}\n          className=\"flex\"\n          exit={{ scale: reduce ? 1 : 0.8, opacity: 0 }}\n          initial={{ scale: reduce ? 1 : 0.8, opacity: 0 }}\n          key={status === \"pending\" ? `n${index}` : status}\n          transition={{ duration: reduce ? 0.1 : 0.18, ease: [0.23, 1, 0.32, 1] }}\n        >\n          {status === \"pending\" && index + 1}\n          {status === \"running\" && (\n            <svg aria-hidden className={cn(\"size-3.5\", !reduce && \"animate-spin\")} viewBox=\"0 0 16 16\">\n              <circle cx=\"8\" cy=\"8\" fill=\"none\" opacity=\"0.25\" r=\"6\" stroke=\"currentColor\" strokeWidth=\"2.5\" />\n              <path d=\"M8 2a6 6 0 0 1 6 6\" fill=\"none\" stroke=\"currentColor\" strokeLinecap=\"round\" strokeWidth=\"2.5\" />\n            </svg>\n          )}\n          {status === \"done\" && <CheckIcon className=\"size-3.5\" strokeWidth={3} />}\n          {status === \"failed\" && <XIcon className=\"size-3.5\" strokeWidth={3} />}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n"
    }
  ]
}