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