Browse all components
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.
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-gateFollows 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-gateComponent 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"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
Status for an AI agent working on its own — running, waiting on you, completed or failed, with what it did last.
The human-in-the-loop confirmation — what the agent wants to do, the exact details, and allow or deny before it acts.
The checklist an agent writes for itself and then edits mid-run — steps added, skipped and re-ordered, without the list jumping under the reader.
One agent passing work to another — who asked, what was carried across, and what the receiving agent was not told.