Skip to content
Scrim UI

Approval Gate UI

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.

approvalhuman-in-the-loopagentresumable

Preview

Post a comment on issue #482

triage-agent is requesting approval

Step 3 of 6 · the run is paused until this is answered.

POST /repos/acme/api/issues/482/comments
{ "body": "Reproduced on 16.3.3 — the offsets are dropped in chunk()." }

Presets

Props

Agent promptClaude Code · Cursor · any agent
Add the Approval Gate UI component from the Scrim UI registry to this project and use it in the configuration described below.

Approval Gate UI — 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.

## 1. Install

```bash
npx shadcn@latest add @scrimui/approval-gate
```

This writes a single file to `components/ui/approval-gate.tsx`. It is plain React + Tailwind with no runtime dependencies — no Radix, no CVA, nothing to add to package.json. If this project does not use the shadcn CLI, copy the source from https://scrimui.dev/components/approval-gate to the same path by hand; nothing in the file depends on shadcn.

## 2. Use it

```tsx
import { ApprovalGate } from "@/components/ui/approval-gate";

const request = {
  id: "req_9f2c41",                       // idempotency key — send it with the decision
  title: "Post a comment on issue #482",
  requester: "triage-agent",
  description: "Step 3 of 6 · the run is paused until this is answered.",
  detail: 'POST /repos/acme/api/issues/482/comments\n{ "body": "…" }',
  expiresAt: Date.now() + 272_000,
};

// `outcome` is undefined: the run has not told us anything yet.
<ApprovalGate
  request={request}
  connection={"live"}
  onDecide={(decision, id) => submitDecision(id, decision)}
/>
```

## 3. The configuration that matters

This is the component's "Pending" state — The run is blocked. The countdown is the run's deadline, not a UI animation — it is why the other four states exist.

Every prop is at its default value. Keep the call site minimal — do not write out default values.

Reference: https://scrimui.dev/components/approval-gate

Follows the props below — change a control and the prompt changes with it, so an agent reproduces that configuration instead of the defaults.

Install

npx shadcn@latest add @scrimui/approval-gate

Component source

React + Tailwind component using shadcn/ui (button, card). The install command adds these dependencies automatically.

When copying source manually, install the required primitives first:

npx shadcn@latest add button card
approval-gate.tsx
"use client";

import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import * as React from "react";

/**
 * The pause that asks "should I actually do this?" — as a lifecycle rather
 * than a card.
 *
 * The card is the easy half. Two buttons, an allow and a deny, and if the
 * agent run lived inside this component that would be the whole job. It does
 * not. An approval is a **blocking decision inside a streaming transport**,
 * and every hard part of this component comes from that one sentence:
 *
 *  - **The tab closes mid-approval.** If the pending state lives in React,
 *    the decision dies with the tab and the run waits forever for an answer
 *    nobody can give any more.
 *  - **The same run is open in two tabs.** Both show the gate. Both can be
 *    clicked. If each tab renders its own optimistic result, one of them is
 *    lying — and if the second click reaches the server, an "approve" and a
 *    "deny" race for the same action.
 *  - **The decision lands after the request expired.** The click was real and
 *    the answer is honest, and the run stopped waiting four minutes ago. The
 *    worst thing to render here is a green tick.
 *
 * So: **this component owns no decision.** `outcome` is a projection of what
 * the run says happened — read from the event log, replayed on reconnect,
 * identical in every tab. `submitting` is the only local state, and it means
 * one thing: a request is in flight from *this* tab. It is deliberately not
 * an optimistic outcome, because the server is allowed to disagree with it,
 * and the two states it disagrees with are the two above.
 *
 * `request.id` is the idempotency key, not a React key. Send it with the
 * decision. Two tabs, a double click and a retried fetch then collapse into
 * one decision on the server, which is the only place they can be collapsed.
 */

export type ApprovalDecision = "approved" | "denied";

export type ApprovalRequest = {
  /** Idempotency key. Send it with the decision; the server dedupes on it. */
  id: string;
  title: string;
  requester?: string;
  description?: string;
  /** The exact thing that will happen. A shell command, a diff, a payload. */
  detail?: string;
  /** Epoch ms after which the run stops waiting. Omit for no deadline. */
  expiresAt?: number;
};

export type ApprovalOutcome = {
  decision: ApprovalDecision;
  /** Who decided, if it was not the person looking at this. */
  decidedBy?: string;
  /** Epoch ms. */
  at?: number;
  /**
   * The server accepted the decision but the run had already stopped waiting.
   * The answer was recorded; the action did not happen. Rendering this as an
   * ordinary approval is the single most misleading thing this component
   * could do.
   */
  stale?: boolean;
};

export type ApprovalGateProps = {
  request: ApprovalRequest;
  /**
   * What the RUN says happened. Undefined means still pending. Comes from the
   * event stream, never from a click handler in this component.
   */
  outcome?: ApprovalOutcome;
  /**
   * A decision in flight from this tab. Set it when the request goes out,
   * clear it when the outcome arrives over the stream — not when the fetch
   * resolves, because the fetch resolving is not the run agreeing.
   */
  submitting?: ApprovalDecision;
  /**
   * The event stream's health. While `reconnecting`, a pending gate might
   * already have been decided somewhere else and this tab has not heard yet,
   * so the buttons say so instead of pretending to be authoritative.
   */
  connection?: "live" | "reconnecting" | "offline";
  /** Must send `request.id`. Fire and forget — the outcome arrives on the stream. */
  onDecide?: (decision: ApprovalDecision, requestId: string) => void;
  /** Injectable clock, so the countdown is testable and the demos are stable. */
  now?: number;
  className?: string;
};

/* ------------------------------------------------------------------ */
/* Icons                                                               */
/* ------------------------------------------------------------------ */

function ShieldIcon(props: React.SVGProps<SVGSVGElement>) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" width="16" height="16" {...props}>
      <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" />
      <path d="m9 12 2 2 4-4" />
    </svg>
  );
}

function CheckIcon(props: React.SVGProps<SVGSVGElement>) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" width="12" height="12" {...props}>
      <path d="M20 6 9 17l-5-5" />
    </svg>
  );
}

function XIcon(props: React.SVGProps<SVGSVGElement>) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" width="12" height="12" {...props}>
      <path d="M18 6 6 18M6 6l12 12" />
    </svg>
  );
}

function ClockIcon(props: React.SVGProps<SVGSVGElement>) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" width="12" height="12" {...props}>
      <circle cx="12" cy="12" r="9" />
      <path d="M12 7v5l3 2" />
    </svg>
  );
}

function Spinner(props: React.SVGProps<SVGSVGElement>) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" width="12" height="12" className="animate-spin" {...props}>
      <path d="M12 3a9 9 0 1 0 9 9" />
    </svg>
  );
}

/* ------------------------------------------------------------------ */
/* Time                                                                */
/* ------------------------------------------------------------------ */

function formatDuration(ms: number): string {
  const s = Math.max(0, Math.round(ms / 1000));
  if (s < 60) return `${s}s`;
  const m = Math.floor(s / 60);
  if (m < 60) return s % 60 === 0 ? `${m}m` : `${m}m ${s % 60}s`;
  return `${Math.floor(m / 60)}h ${m % 60}m`;
}

/**
 * A clock that only runs when something on screen depends on it.
 *
 * `now` from the host wins — the run already has a clock, and two clocks
 * disagreeing by a second is how a countdown reads 0s next to an active
 * button. Falls back to a local tick, started only while a deadline is
 * pending, so a settled gate is not re-rendering once a second forever.
 */
function useNow(provided: number | undefined, active: boolean): number {
  const [tick, setTick] = React.useState(() => Date.now());
  React.useEffect(() => {
    if (provided !== undefined || !active) return;
    const id = setInterval(() => setTick(Date.now()), 1000);
    return () => clearInterval(id);
  }, [provided, active]);
  return provided ?? tick;
}

/* ------------------------------------------------------------------ */
/* ApprovalGate                                                        */
/* ------------------------------------------------------------------ */

export function ApprovalGate({
  request,
  outcome,
  submitting,
  connection = "live",
  onDecide,
  now: providedNow,
  className = "",
}: ApprovalGateProps) {
  const pending = outcome === undefined;
  const now = useNow(providedNow, pending && request.expiresAt !== undefined);

  const remaining = request.expiresAt === undefined ? undefined : request.expiresAt - now;
  const expired = pending && remaining !== undefined && remaining <= 0;
  /* Urgency is a colour change, not a countdown that turns red at the end and
     surprises someone who looked away. Thirty seconds is roughly the point a
     reader can still act. */
  const urgent = remaining !== undefined && remaining > 0 && remaining < 30_000;

  const actionable = pending && !expired && submitting === undefined;
  const tone = outcome
    ? outcome.stale
      ? "amber"
      : outcome.decision === "approved"
        ? "emerald"
        : "red"
    : expired
      ? "zinc"
      : "amber";

  return (
    <Card
      className={`gap-0 py-0 rounded-xl border bg-card p-4 ${
        expired
          ? "border-border"
          : outcome
            ? "border-border"
            : "border-amber-200 dark:border-amber-900/60"
      } ${className}`}
    >
      <div className="flex items-start gap-3">
        <span
          className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${
            tone === "emerald"
              ? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400"
              : tone === "red"
                ? "bg-red-100 text-red-600 dark:bg-red-900/40 dark:text-red-400"
                : tone === "zinc"
                  ? "bg-muted text-muted-foreground"
                  : "bg-amber-100 text-amber-600 dark:bg-amber-900/40 dark:text-amber-400"
          }`}
        >
          {expired ? <ClockIcon width="16" height="16" /> : <ShieldIcon />}
        </span>

        <div className="min-w-0 flex-1">
          <div className="flex items-start justify-between gap-3">
            <p className="text-sm font-medium text-foreground">{request.title}</p>
            {pending && remaining !== undefined && !expired && (
              <span
                /* aria-live off: a countdown announced every second is a
                   screen reader nobody can use. The deadline is in the
                   button's own label instead. */
                aria-hidden
                className={`shrink-0 tabular-nums text-xs ${
                  urgent ? "text-amber-600 dark:text-amber-500" : "text-muted-foreground"
                }`}
              >
                {formatDuration(remaining)} left
              </span>
            )}
          </div>

          {request.requester && (
            <p className="mt-0.5 text-xs text-muted-foreground">
              {request.requester} is requesting approval
            </p>
          )}
          {request.description && (
            <p className="mt-1.5 text-sm leading-5 text-muted-foreground">
              {request.description}
            </p>
          )}
          {request.detail && (
            <pre className="mt-2 overflow-x-auto rounded-lg bg-muted p-2.5 font-mono text-xs leading-5 text-foreground">
              {request.detail}
            </pre>
          )}

          {/* ---------------- pending ---------------- */}
          {pending && !expired && (
            <>
              <div className="mt-3 flex flex-wrap items-center gap-2">
                <Button variant="default" size="sm"
                  type="button"
                  onClick={() => onDecide?.("approved", request.id)}
                  disabled={!actionable}

                  className="inline-flex h-8 items-center gap-1.5 rounded-md bg-primary px-3.5 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-50"
                >
                  {submitting === "approved" ? <Spinner /> : <CheckIcon />}
                  Allow
                </Button>
                <Button variant="outline" size="sm"
                  type="button"
                  onClick={() => onDecide?.("denied", request.id)}
                  disabled={!actionable}
                  className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-3.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted disabled:opacity-50"
                >
                  {submitting === "denied" ? <Spinner /> : <XIcon />}
                  Deny
                </Button>
              </div>

              {/* The buttons stay visible and go inert rather than being
                  replaced. A card that empties itself the instant you click
                  reads as "did that work?" — and the answer is still in
                  flight, so the honest thing is to say so. */}
              {submitting !== undefined && (
                <p className="mt-2 text-xs text-muted-foreground">
                  Sent. Waiting for the run to confirm — this is not decided until it does.
                </p>
              )}

              {submitting === undefined && connection !== "live" && (
                <p className="mt-2 text-xs text-amber-600 dark:text-amber-500">
                  {connection === "reconnecting"
                    ? "Reconnecting — this may already have been decided elsewhere."
                    : "Offline — a decision cannot be sent until the run is reachable again."}
                </p>
              )}
            </>
          )}

          {/* ---------------- expired ---------------- */}
          {expired && (
            <p className="mt-3 inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
              <ClockIcon />
              Expired {formatDuration(-remaining!)} ago — the run stopped waiting and did not act.
            </p>
          )}

          {/* ---------------- settled ---------------- */}
          {outcome && (
            <div className="mt-3">
              <p
                className={`inline-flex items-center gap-1.5 text-xs font-medium ${
                  outcome.stale
                    ? "text-amber-600 dark:text-amber-500"
                    : outcome.decision === "approved"
                      ? /* -700 in light: emerald-600 on white is 3.65:1 at 12px. */
                        "text-emerald-700 dark:text-emerald-400"
                      : "text-red-600 dark:text-red-400"
                }`}
              >
                {outcome.stale ? <ClockIcon /> : outcome.decision === "approved" ? <CheckIcon /> : <XIcon />}
                {outcome.stale
                  ? `Recorded as ${outcome.decision}, but too late — the run had already stopped waiting, and the action did not run.`
                  : outcome.decision === "approved"
                    ? "Approved — action executed"
                    : "Denied — action blocked"}
              </p>
              {outcome.decidedBy && (
                /* Named, because on a shared run "who clicked allow" is the
                   first question asked afterwards, and the second tab that
                   was watching deserves to know it was not ignored. */
                <p className="mt-1 text-xs text-muted-foreground">
                  by {outcome.decidedBy}
                  {outcome.at !== undefined && ` · ${formatDuration(now - outcome.at)} ago`}
                </p>
              )}
            </div>
          )}
        </div>
      </div>
    </Card>
  );
}

When to use it

  • Feed `outcome` from the run's event log, not from the click handler. Every hard case here — a closed tab, a second tab, a reload — stops being a case at all once the outcome has a single source outside React.
  • Send `request.id` with the decision and dedupe on it server-side. Two tabs, a double click and a retried fetch are three ways to send the same decision twice.
  • Clear `submitting` when the outcome arrives over the stream, not when the fetch resolves. A 200 means the decision was received; it does not mean the run acted on it.
  • Set `expiresAt` to the run's own deadline. Derived state beats stored state — the expired render is then correct on a reload months later, with no expiry event needed.
  • Show the exact payload in `detail`. The question is 'should I do this?', and it cannot be answered from a summary of what 'this' is.
  • Pass `connection` from the stream's health so a pending gate stops claiming to be current while the tab is catching up.

What breaks in production

  • Keeping the pending state in React. The tab closes, the state goes with it, and the run waits forever for an answer nobody can give any more.
  • Rendering the decision optimistically. With the run open in two tabs, one of them is then showing a decision the server may reject, and there is no honest way back from a green tick.
  • Rendering a late decision as a normal approval. It is the one state a tick actively lies about: the answer was recorded and the action never ran.
  • Hiding the buttons the moment they are clicked. The card empties, the answer is still in flight, and the reader has no way to tell 'sent' from 'ignored'.
  • Letting the deadline expire silently into the pending state. A gate that just sits there is indistinguishable from a run that is still waiting.
  • Announcing the countdown to screen readers. Once a second, forever, over whatever the reader was actually trying to hear.

Related Components