{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "approval-request",
  "title": "Approval Request",
  "description": "A stable, explicit pause for a human decision inside an agent run — never a spinner standing in for consent.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "button",
    "springs"
  ],
  "files": [
    {
      "path": "src/components/trovecn/ai-workbench/approval-request.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useState, type ReactNode } from \"react\";\nimport { CheckIcon, ChevronDownIcon, Loader2Icon, RotateCcwIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { spring } from \"@/lib/springs\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ApprovalRequestStatus = \"pending\" | \"approved\" | \"denied\" | \"expired\" | \"cancelled\";\n\nexport interface ApprovalRequestDetail {\n  summary: ReactNode;\n  /** Compact key:value parameters, rendered in mono — counts, paths, filters. */\n  items?: readonly ReactNode[];\n  /** Full-sentence content (a message draft, a diff) rendered as body copy instead of mono. */\n  preview?: ReactNode;\n}\n\nexport interface ApprovalRequestProps {\n  id: string;\n  /** The action being approved, stated as what will happen — not a question. */\n  title: ReactNode;\n  /** One-line requester context: which agent or tool call, and where in the run. */\n  context?: ReactNode;\n  detail?: ApprovalRequestDetail;\n  /** Freeform label for whichever category applies (\"Irreversible,\" \"Customer-facing,\" …). No fixed vocabulary — the caller writes it. */\n  flag?: ReactNode;\n  /** Binary, not a severity scale: trove/cn has one semantic warning colour. */\n  flagTone?: \"default\" | \"critical\";\n  /** Caller-owned source of truth. This component renders it; it never sets it itself. */\n  status: ApprovalRequestStatus;\n  /** Renders a static deadline label. Not a timer — the caller still drives the transition to \"expired\". */\n  expiresAt?: Date;\n  decidedBy?: ReactNode;\n  decidedAt?: ReactNode;\n  /** A rejected promise renders the anchored inline error state with Retry — never a toast. */\n  onApprove?: () => void | Promise<void>;\n  onDeny?: () => void | Promise<void>;\n  /**\n   * \"card\" (default) is the standalone, persistent-in-stream surface this\n   * component is built for. \"embedded\" strips its own card chrome for the\n   * one sanctioned nesting case — ToolRun handing its body off to this\n   * component for the needs-approval status — so the pause reads as one\n   * card, not two stacked shadows.\n   */\n  variant?: \"card\" | \"embedded\";\n  className?: string;\n}\n\ntype DecisionAction = \"approve\" | \"deny\";\ntype Phase = \"pending\" | \"submitting\" | \"error\" | \"resolved\";\n\nconst RESOLVED_COPY: Record<\n  Exclude<ApprovalRequestStatus, \"pending\">,\n  { icon: typeof CheckIcon; label: string; tone: \"success\" | \"muted\" | \"destructive\" }\n> = {\n  approved: { icon: CheckIcon, label: \"Approved\", tone: \"success\" },\n  denied: { icon: XIcon, label: \"Denied\", tone: \"muted\" },\n  expired: { icon: XIcon, label: \"Expired\", tone: \"destructive\" },\n  cancelled: { icon: XIcon, label: \"Cancelled\", tone: \"muted\" },\n};\n\nfunction ApprovalRequestDetailDisclosure({ detail }: { detail: ApprovalRequestDetail }) {\n  const [isOpen, setIsOpen] = useState(false);\n  const contentId = useId();\n  const reduceMotion = useReducedMotion();\n\n  return (\n    <div className=\"mt-1.5\">\n      <button\n        type=\"button\"\n        aria-expanded={isOpen}\n        aria-controls={contentId}\n        onClick={() => setIsOpen((open) => !open)}\n        className=\"group relative inline-flex items-center gap-1 text-caption text-muted-foreground transition-colors duration-quick before:absolute before:-inset-3 before:content-[''] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n      >\n        <span>{detail.summary}</span>\n        <motion.span\n          animate={{ transform: `rotate(${isOpen ? 180 : 0}deg)` }}\n          transition={reduceMotion ? { duration: 0 } : spring.quick.enter}\n        >\n          <ChevronDownIcon className=\"size-3\" />\n        </motion.span>\n      </button>\n      <AnimatePresence initial={false}>\n        {isOpen ? (\n          <motion.div\n            id={contentId}\n            initial={reduceMotion ? { opacity: 0 } : { height: 0, opacity: 0 }}\n            animate={reduceMotion ? { opacity: 1 } : { height: \"auto\", opacity: 1 }}\n            exit={reduceMotion ? { opacity: 0 } : { height: 0, opacity: 0 }}\n            transition={reduceMotion ? spring.quick.exit : spring.moderate.enter}\n            className=\"overflow-hidden\"\n          >\n            <div className=\"mt-1.5 flex flex-col gap-1.5\">\n              {detail.preview ? (\n                <div className=\"shadow-well rounded-md bg-background px-2.5 py-2 text-body leading-relaxed text-foreground\">\n                  {detail.preview}\n                </div>\n              ) : null}\n              {detail.items && detail.items.length > 0 ? (\n                <div className=\"flex flex-col gap-1 rounded-md bg-muted px-2.5 py-2 font-mono text-meta text-muted-foreground\">\n                  {detail.items.map((item, index) => (\n                    <span key={index}>{item}</span>\n                  ))}\n                </div>\n              ) : null}\n            </div>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n}\n\n/**\n * A stable, explicit pause for a human decision inside an agent run — never a\n * spinner standing in for consent. Inline and persistent in its surrounding\n * stream rather than a Dialog, so the pause can't be Escape-dismissed and the\n * context that justifies it never leaves view. Motion story: the action row\n * leaves before the resolved row claims its space, and the card itself never\n * moves or dismisses once a decision lands — resolving must not make the\n * decision disappear.\n */\nfunction ApprovalRequest({\n  title,\n  context,\n  detail,\n  flag,\n  flagTone = \"default\",\n  status,\n  expiresAt,\n  decidedBy,\n  decidedAt,\n  onApprove,\n  onDeny,\n  variant = \"card\",\n  className,\n}: ApprovalRequestProps) {\n  const reduceMotion = useReducedMotion();\n  const titleId = useId();\n  const [pendingAction, setPendingAction] = useState<DecisionAction | null>(null);\n  const [lastAction, setLastAction] = useState<DecisionAction | null>(null);\n  const [submitError, setSubmitError] = useState<string | null>(null);\n  const [deadlineLabel, setDeadlineLabel] = useState<string | null>(null);\n\n  // Computed after mount only — locale-formatted time can differ between the\n  // server's timezone and the browser's, which would otherwise be a\n  // hydration mismatch. A brief blank beat is preferable to a wrong one.\n  useEffect(() => {\n    if (!expiresAt) {\n      setDeadlineLabel(null);\n      return;\n    }\n    setDeadlineLabel(\n      expiresAt.toLocaleTimeString(undefined, { hour: \"numeric\", minute: \"2-digit\" }),\n    );\n  }, [expiresAt]);\n\n  const phase: Phase =\n    status !== \"pending\"\n      ? \"resolved\"\n      : submitError\n        ? \"error\"\n        : pendingAction\n          ? \"submitting\"\n          : \"pending\";\n\n  async function handleDecision(action: DecisionAction) {\n    const handler = action === \"approve\" ? onApprove : onDeny;\n    setLastAction(action);\n    setSubmitError(null);\n    setPendingAction(action);\n    try {\n      await handler?.();\n    } catch {\n      setSubmitError(\n        action === \"approve\"\n          ? \"Couldn’t submit the approval. Try again.\"\n          : \"Couldn’t submit the denial. Try again.\",\n      );\n    } finally {\n      setPendingAction(null);\n    }\n  }\n\n  const resolved = status !== \"pending\" ? RESOLVED_COPY[status] : null;\n  const ResolvedIcon = resolved?.icon;\n\n  return (\n    <motion.section\n      data-slot=\"approval-request\"\n      role=\"group\"\n      aria-labelledby={titleId}\n      initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6 }}\n      animate={{ opacity: 1, y: 0 }}\n      transition={reduceMotion ? spring.quick.exit : spring.moderate.enter}\n      className={cn(\n        \"w-full\",\n        variant === \"card\" ? \"shadow-bevel max-w-md rounded-lg bg-card px-4 py-3.5\" : \"max-w-none\",\n        className,\n      )}\n    >\n      {context || flag ? (\n        <div className=\"flex flex-wrap items-center gap-1.5\">\n          {context ? <p className=\"m-0 text-meta text-muted-foreground\">{context}</p> : null}\n          {flag ? (\n            <span\n              className={cn(\n                \"ml-auto font-mono text-micro tracking-wide uppercase\",\n                flagTone === \"critical\"\n                  ? \"text-destructive underline decoration-destructive/40 underline-offset-2\"\n                  : \"text-muted-foreground\",\n              )}\n            >\n              {flag}\n            </span>\n          ) : null}\n        </div>\n      ) : null}\n\n      <p id={titleId} className=\"mt-0.5 text-control leading-snug text-foreground\">\n        {title}\n      </p>\n\n      {deadlineLabel ? (\n        <p className=\"mt-1 font-mono text-micro text-muted-foreground\">Expires {deadlineLabel}</p>\n      ) : null}\n\n      {detail ? <ApprovalRequestDetailDisclosure detail={detail} /> : null}\n\n      <div className=\"relative mt-3 min-h-8\" aria-live=\"polite\">\n        <AnimatePresence mode=\"wait\" initial={false}>\n          {phase === \"pending\" || phase === \"submitting\" ? (\n            <motion.div\n              key=\"actions\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0, transition: reduceMotion ? { duration: 0 } : spring.quick.exit }}\n              transition={reduceMotion ? { duration: 0 } : spring.quick.enter}\n              className=\"flex items-center gap-2\"\n            >\n              <Button\n                type=\"button\"\n                size=\"sm\"\n                disabled={phase === \"submitting\"}\n                onClick={() => void handleDecision(\"approve\")}\n                className=\"relative before:absolute before:-top-2 before:-bottom-2 before:inset-x-0 before:content-['']\"\n              >\n                {pendingAction === \"approve\" ? (\n                  <>\n                    <Loader2Icon\n                      className={cn(\"size-3.5\", !reduceMotion && \"animate-spin\")}\n                      aria-hidden=\"true\"\n                    />\n                    Approving…\n                  </>\n                ) : (\n                  \"Approve\"\n                )}\n              </Button>\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                size=\"sm\"\n                disabled={phase === \"submitting\"}\n                onClick={() => void handleDecision(\"deny\")}\n                className=\"relative before:absolute before:-top-2 before:-bottom-2 before:inset-x-0 before:content-['']\"\n              >\n                {pendingAction === \"deny\" ? (\n                  <>\n                    <Loader2Icon\n                      className={cn(\"size-3.5\", !reduceMotion && \"animate-spin\")}\n                      aria-hidden=\"true\"\n                    />\n                    Denying…\n                  </>\n                ) : (\n                  \"Deny\"\n                )}\n              </Button>\n            </motion.div>\n          ) : phase === \"error\" ? (\n            <motion.div\n              key=\"error\"\n              initial={{ opacity: 0, y: reduceMotion ? 0 : 3 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, transition: reduceMotion ? { duration: 0 } : spring.quick.exit }}\n              transition={reduceMotion ? { duration: 0 } : spring.quick.enter}\n              className=\"flex flex-wrap items-center gap-2\"\n            >\n              <span className=\"text-caption text-destructive\">{submitError}</span>\n              <Button\n                type=\"button\"\n                variant=\"secondary\"\n                size=\"sm\"\n                onClick={() => lastAction && void handleDecision(lastAction)}\n              >\n                <RotateCcwIcon className=\"size-3.5\" />\n                Retry\n              </Button>\n            </motion.div>\n          ) : resolved && ResolvedIcon ? (\n            <motion.div\n              key=\"resolved\"\n              initial={{ opacity: 0, y: reduceMotion ? 0 : 3 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, transition: reduceMotion ? { duration: 0 } : spring.quick.exit }}\n              transition={reduceMotion ? { duration: 0 } : spring.quick.enter}\n              className=\"flex items-center gap-1.5 text-caption text-muted-foreground\"\n            >\n              <ResolvedIcon\n                className={cn(\n                  \"size-3.5 shrink-0\",\n                  resolved.tone === \"success\" && \"text-success\",\n                  resolved.tone === \"destructive\" && \"text-destructive\",\n                  resolved.tone === \"muted\" && \"text-muted-foreground\",\n                )}\n                aria-hidden=\"true\"\n              />\n              <span>\n                <span\n                  className={cn(\n                    \"font-medium\",\n                    resolved.tone === \"destructive\" ? \"text-destructive\" : \"text-foreground\",\n                  )}\n                >\n                  {resolved.label}\n                </span>\n                {decidedBy ? <> by {decidedBy}</> : null}\n                {decidedAt ? <> · {decidedAt}</> : null}\n              </span>\n            </motion.div>\n          ) : null}\n        </AnimatePresence>\n      </div>\n    </motion.section>\n  );\n}\n\nexport { ApprovalRequest };\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}