{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"approval-gate","type":"registry:ui","title":"Approval Gate","description":"The approval card as a lifecycle: a decision that survives a closed tab, is idempotent across two open ones, and says what happened when it lands after the request expired.","author":"Scrim UI (https://scrimui.dev)","categories":["agents"],"docs":"https://scrimui.dev/components/approval-gate","dependencies":[],"registryDependencies":[],"files":[{"path":"src/showcase/approval-gate/approval-gate.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/**\n * The pause that asks \"should I actually do this?\" — as a lifecycle rather\n * than a card.\n *\n * The card is the easy half. Two buttons, an allow and a deny, and if the\n * agent run lived inside this component that would be the whole job. It does\n * not. An approval is a **blocking decision inside a streaming transport**,\n * and every hard part of this component comes from that one sentence:\n *\n *  - **The tab closes mid-approval.** If the pending state lives in React,\n *    the decision dies with the tab and the run waits forever for an answer\n *    nobody can give any more.\n *  - **The same run is open in two tabs.** Both show the gate. Both can be\n *    clicked. If each tab renders its own optimistic result, one of them is\n *    lying — and if the second click reaches the server, an \"approve\" and a\n *    \"deny\" race for the same action.\n *  - **The decision lands after the request expired.** The click was real and\n *    the answer is honest, and the run stopped waiting four minutes ago. The\n *    worst thing to render here is a green tick.\n *\n * So: **this component owns no decision.** `outcome` is a projection of what\n * the run says happened — read from the event log, replayed on reconnect,\n * identical in every tab. `submitting` is the only local state, and it means\n * one thing: a request is in flight from *this* tab. It is deliberately not\n * an optimistic outcome, because the server is allowed to disagree with it,\n * and the two states it disagrees with are the two above.\n *\n * `request.id` is the idempotency key, not a React key. Send it with the\n * decision. Two tabs, a double click and a retried fetch then collapse into\n * one decision on the server, which is the only place they can be collapsed.\n */\n\nexport type ApprovalDecision = \"approved\" | \"denied\";\n\nexport type ApprovalRequest = {\n  /** Idempotency key. Send it with the decision; the server dedupes on it. */\n  id: string;\n  title: string;\n  requester?: string;\n  description?: string;\n  /** The exact thing that will happen. A shell command, a diff, a payload. */\n  detail?: string;\n  /** Epoch ms after which the run stops waiting. Omit for no deadline. */\n  expiresAt?: number;\n};\n\nexport type ApprovalOutcome = {\n  decision: ApprovalDecision;\n  /** Who decided, if it was not the person looking at this. */\n  decidedBy?: string;\n  /** Epoch ms. */\n  at?: number;\n  /**\n   * The server accepted the decision but the run had already stopped waiting.\n   * The answer was recorded; the action did not happen. Rendering this as an\n   * ordinary approval is the single most misleading thing this component\n   * could do.\n   */\n  stale?: boolean;\n};\n\nexport type ApprovalGateProps = {\n  request: ApprovalRequest;\n  /**\n   * What the RUN says happened. Undefined means still pending. Comes from the\n   * event stream, never from a click handler in this component.\n   */\n  outcome?: ApprovalOutcome;\n  /**\n   * A decision in flight from this tab. Set it when the request goes out,\n   * clear it when the outcome arrives over the stream — not when the fetch\n   * resolves, because the fetch resolving is not the run agreeing.\n   */\n  submitting?: ApprovalDecision;\n  /**\n   * The event stream's health. While `reconnecting`, a pending gate might\n   * already have been decided somewhere else and this tab has not heard yet,\n   * so the buttons say so instead of pretending to be authoritative.\n   */\n  connection?: \"live\" | \"reconnecting\" | \"offline\";\n  /** Must send `request.id`. Fire and forget — the outcome arrives on the stream. */\n  onDecide?: (decision: ApprovalDecision, requestId: string) => void;\n  /** Injectable clock, so the countdown is testable and the demos are stable. */\n  now?: number;\n  className?: string;\n};\n\n/* ------------------------------------------------------------------ */\n/* Icons                                                               */\n/* ------------------------------------------------------------------ */\n\nfunction ShieldIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"16\" height=\"16\" {...props}>\n      <path d=\"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z\" />\n      <path d=\"m9 12 2 2 4-4\" />\n    </svg>\n  );\n}\n\nfunction CheckIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"12\" height=\"12\" {...props}>\n      <path d=\"M20 6 9 17l-5-5\" />\n    </svg>\n  );\n}\n\nfunction XIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"12\" height=\"12\" {...props}>\n      <path d=\"M18 6 6 18M6 6l12 12\" />\n    </svg>\n  );\n}\n\nfunction ClockIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"12\" height=\"12\" {...props}>\n      <circle cx=\"12\" cy=\"12\" r=\"9\" />\n      <path d=\"M12 7v5l3 2\" />\n    </svg>\n  );\n}\n\nfunction Spinner(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\" width=\"12\" height=\"12\" className=\"animate-spin\" {...props}>\n      <path d=\"M12 3a9 9 0 1 0 9 9\" />\n    </svg>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* Time                                                                */\n/* ------------------------------------------------------------------ */\n\nfunction formatDuration(ms: number): string {\n  const s = Math.max(0, Math.round(ms / 1000));\n  if (s < 60) return `${s}s`;\n  const m = Math.floor(s / 60);\n  if (m < 60) return s % 60 === 0 ? `${m}m` : `${m}m ${s % 60}s`;\n  return `${Math.floor(m / 60)}h ${m % 60}m`;\n}\n\n/**\n * A clock that only runs when something on screen depends on it.\n *\n * `now` from the host wins — the run already has a clock, and two clocks\n * disagreeing by a second is how a countdown reads 0s next to an active\n * button. Falls back to a local tick, started only while a deadline is\n * pending, so a settled gate is not re-rendering once a second forever.\n */\nfunction useNow(provided: number | undefined, active: boolean): number {\n  const [tick, setTick] = React.useState(() => Date.now());\n  React.useEffect(() => {\n    if (provided !== undefined || !active) return;\n    const id = setInterval(() => setTick(Date.now()), 1000);\n    return () => clearInterval(id);\n  }, [provided, active]);\n  return provided ?? tick;\n}\n\n/* ------------------------------------------------------------------ */\n/* ApprovalGate                                                        */\n/* ------------------------------------------------------------------ */\n\nexport function ApprovalGate({\n  request,\n  outcome,\n  submitting,\n  connection = \"live\",\n  onDecide,\n  now: providedNow,\n  className = \"\",\n}: ApprovalGateProps) {\n  const pending = outcome === undefined;\n  const now = useNow(providedNow, pending && request.expiresAt !== undefined);\n\n  const remaining = request.expiresAt === undefined ? undefined : request.expiresAt - now;\n  const expired = pending && remaining !== undefined && remaining <= 0;\n  /* Urgency is a colour change, not a countdown that turns red at the end and\n     surprises someone who looked away. Thirty seconds is roughly the point a\n     reader can still act. */\n  const urgent = remaining !== undefined && remaining > 0 && remaining < 30_000;\n\n  const actionable = pending && !expired && submitting === undefined;\n  const tone = outcome\n    ? outcome.stale\n      ? \"amber\"\n      : outcome.decision === \"approved\"\n        ? \"emerald\"\n        : \"red\"\n    : expired\n      ? \"zinc\"\n      : \"amber\";\n\n  return (\n    <div\n      className={`rounded-xl border bg-white p-4 dark:bg-zinc-900 ${\n        expired\n          ? \"border-zinc-200 dark:border-zinc-800\"\n          : outcome\n            ? \"border-zinc-200 dark:border-zinc-800\"\n            : \"border-amber-200 dark:border-amber-900/60\"\n      } ${className}`}\n    >\n      <div className=\"flex items-start gap-3\">\n        <span\n          className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${\n            tone === \"emerald\"\n              ? \"bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400\"\n              : tone === \"red\"\n                ? \"bg-red-100 text-red-600 dark:bg-red-900/40 dark:text-red-400\"\n                : tone === \"zinc\"\n                  ? \"bg-zinc-100 text-zinc-400 dark:bg-zinc-800 dark:text-zinc-500\"\n                  : \"bg-amber-100 text-amber-600 dark:bg-amber-900/40 dark:text-amber-400\"\n          }`}\n        >\n          {expired ? <ClockIcon width=\"16\" height=\"16\" /> : <ShieldIcon />}\n        </span>\n\n        <div className=\"min-w-0 flex-1\">\n          <div className=\"flex items-start justify-between gap-3\">\n            <p className=\"text-sm font-medium text-zinc-900 dark:text-zinc-100\">{request.title}</p>\n            {pending && remaining !== undefined && !expired && (\n              <span\n                /* aria-live off: a countdown announced every second is a\n                   screen reader nobody can use. The deadline is in the\n                   button's own label instead. */\n                aria-hidden\n                className={`shrink-0 tabular-nums text-[11px] ${\n                  urgent ? \"text-amber-600 dark:text-amber-500\" : \"text-zinc-400 dark:text-zinc-500\"\n                }`}\n              >\n                {formatDuration(remaining)} left\n              </span>\n            )}\n          </div>\n\n          {request.requester && (\n            <p className=\"mt-0.5 text-xs text-zinc-500 dark:text-zinc-400\">\n              {request.requester} is requesting approval\n            </p>\n          )}\n          {request.description && (\n            <p className=\"mt-1.5 text-[13px] leading-5 text-zinc-500 dark:text-zinc-400\">\n              {request.description}\n            </p>\n          )}\n          {request.detail && (\n            <pre className=\"mt-2 overflow-x-auto rounded-lg bg-zinc-50 p-2.5 font-mono text-xs leading-5 text-zinc-700 dark:bg-zinc-800/60 dark:text-zinc-300\">\n              {request.detail}\n            </pre>\n          )}\n\n          {/* ---------------- pending ---------------- */}\n          {pending && !expired && (\n            <>\n              <div className=\"mt-3 flex flex-wrap items-center gap-2\">\n                <button\n                  type=\"button\"\n                  onClick={() => onDecide?.(\"approved\", request.id)}\n                  disabled={!actionable}\n                  /* emerald-700, not -600: white on emerald-600 is 3.65:1,\n                     under the 4.5 floor for this 12px label. -700 is 5.48:1. */\n                  className=\"inline-flex h-8 items-center gap-1.5 rounded-lg bg-emerald-700 px-3.5 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50\"\n                >\n                  {submitting === \"approved\" ? <Spinner /> : <CheckIcon />}\n                  Allow\n                </button>\n                <button\n                  type=\"button\"\n                  onClick={() => onDecide?.(\"denied\", request.id)}\n                  disabled={!actionable}\n                  className=\"inline-flex h-8 items-center gap-1.5 rounded-lg border border-zinc-200 px-3.5 text-xs font-medium text-zinc-600 transition-colors hover:bg-zinc-100 disabled:opacity-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800\"\n                >\n                  {submitting === \"denied\" ? <Spinner /> : <XIcon />}\n                  Deny\n                </button>\n              </div>\n\n              {/* The buttons stay visible and go inert rather than being\n                  replaced. A card that empties itself the instant you click\n                  reads as \"did that work?\" — and the answer is still in\n                  flight, so the honest thing is to say so. */}\n              {submitting !== undefined && (\n                <p className=\"mt-2 text-[11px] text-zinc-500 dark:text-zinc-400\">\n                  Sent. Waiting for the run to confirm — this is not decided until it does.\n                </p>\n              )}\n\n              {submitting === undefined && connection !== \"live\" && (\n                <p className=\"mt-2 text-[11px] text-amber-600 dark:text-amber-500\">\n                  {connection === \"reconnecting\"\n                    ? \"Reconnecting — this may already have been decided elsewhere.\"\n                    : \"Offline — a decision cannot be sent until the run is reachable again.\"}\n                </p>\n              )}\n            </>\n          )}\n\n          {/* ---------------- expired ---------------- */}\n          {expired && (\n            <p className=\"mt-3 inline-flex items-center gap-1.5 text-xs font-medium text-zinc-500 dark:text-zinc-400\">\n              <ClockIcon />\n              Expired {formatDuration(-remaining!)} ago — the run stopped waiting and did not act.\n            </p>\n          )}\n\n          {/* ---------------- settled ---------------- */}\n          {outcome && (\n            <div className=\"mt-3\">\n              <p\n                className={`inline-flex items-center gap-1.5 text-xs font-medium ${\n                  outcome.stale\n                    ? \"text-amber-600 dark:text-amber-500\"\n                    : outcome.decision === \"approved\"\n                      ? /* -700 in light: emerald-600 on white is 3.65:1 at 12px. */\n                        \"text-emerald-700 dark:text-emerald-400\"\n                      : \"text-red-600 dark:text-red-400\"\n                }`}\n              >\n                {outcome.stale ? <ClockIcon /> : outcome.decision === \"approved\" ? <CheckIcon /> : <XIcon />}\n                {outcome.stale\n                  ? `Recorded as ${outcome.decision}, but too late — the run had already stopped waiting, and the action did not run.`\n                  : outcome.decision === \"approved\"\n                    ? \"Approved — action executed\"\n                    : \"Denied — action blocked\"}\n              </p>\n              {outcome.decidedBy && (\n                /* Named, because on a shared run \"who clicked allow\" is the\n                   first question asked afterwards, and the second tab that\n                   was watching deserves to know it was not ignored. */\n                <p className=\"mt-1 text-[11px] text-zinc-500 dark:text-zinc-400\">\n                  by {outcome.decidedBy}\n                  {outcome.at !== undefined && ` · ${formatDuration(now - outcome.at)} ago`}\n                </p>\n              )}\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n","type":"registry:ui","target":"components/ui/approval-gate.tsx"}]}