Agent 审批收件箱
New人机协同审批队列:带风险分级与过期时间的请求项、可展开详情、原地落定的批准/拒绝流转,以及带影响范围、增删统计与撤销的操作回执卡。
"use client";
import { Database, Mail, Trash2 } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { type ReactNode, useState } from "react";
import {
ActionReceipt,
AgentInbox,
type InboxRisk,
InboxItem,
type InboxStatus,
} from "@/components/motion/agent-inbox";
import { EASE_OUT } from "@/lib/ease";
type ItemId = "report" | "migrate" | "cleanup";
interface DemoItem {
id: ItemId;
icon: ReactNode;
source: string;
title: string;
description: string;
risk: InboxRisk;
expires?: string;
status: InboxStatus;
}
const INITIAL_ITEMS: DemoItem[] = [
{
id: "report",
icon: <Mail className="h-4 w-4" />,
source: "report-agent",
title: "Send weekly usage report",
description: "Email to 3 recipients",
risk: "low",
expires: "expires in 6h",
status: "pending",
},
{
id: "migrate",
icon: <Database className="h-4 w-4" />,
source: "migrate-agent",
title: "Run database migration",
description: "2 tables altered, backup taken first",
risk: "medium",
expires: "expires in 2h",
status: "pending",
},
{
id: "cleanup",
icon: <Trash2 className="h-4 w-4" />,
source: "cleanup-agent",
title: "Delete 12 stale branches",
description: "Includes 2 branches with unmerged commits",
risk: "high",
status: "pending",
},
];
/** Ghost "Clear" button in `AgentInbox`'s action slot — decorative, no behavior. */
function ClearButton() {
return (
<button
type="button"
className="h-6 rounded-md px-2 text-muted-foreground text-xs transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10"
>
Clear
</button>
);
}
function resolutionFor(status: InboxStatus) {
if (status === "approved") return "Approved · running";
if (status === "denied") return "Denied";
if (status === "expired") return "Expired";
return undefined;
}
/**
* HITL approval queue — three pending requests spanning the three risk
* tiers, driven by local state. Approving/denying an item flips its status
* (the row dims and the buttons swap for a resolution line); approving the
* low-risk report additionally appends a completed `ActionReceipt` below it,
* fading in via `AnimatePresence`. The header count badge tracks how many
* items remain pending; "Reset" (top-right of the frame) restores the
* initial state.
*/
export function AgentInboxPreview() {
const [items, setItems] = useState<DemoItem[]>(INITIAL_ITEMS);
const pendingCount = items.filter((item) => item.status === "pending").length;
const reportApproved = items.find((item) => item.id === "report")?.status === "approved";
const resolve = (id: ItemId, status: InboxStatus) => {
setItems((prev) => prev.map((item) => (item.id === id ? { ...item, status } : item)));
};
return (
<div className="relative h-[560px] w-full overflow-hidden rounded-xl border border-border bg-neutral-100 dark:bg-neutral-950">
<button
type="button"
onClick={() => setItems(INITIAL_ITEMS)}
className="absolute top-3 right-3 z-10 h-7 rounded-full border border-border bg-white/80 px-3 text-muted-foreground text-xs backdrop-blur transition-colors hover:text-foreground dark:bg-neutral-900/80"
>
Reset
</button>
<div className="mx-auto h-full max-w-md overflow-y-auto px-4 py-8">
<AgentInbox title="Approvals" count={pendingCount} action={<ClearButton />}>
<InboxItem
icon={items[0].icon}
source={items[0].source}
title={items[0].title}
description={items[0].description}
risk={items[0].risk}
expires={items[0].status === "pending" ? items[0].expires : undefined}
status={items[0].status}
resolution={resolutionFor(items[0].status)}
onApprove={() => resolve("report", "approved")}
onDeny={() => resolve("report", "denied")}
>
<div className="flex flex-col gap-0.5 text-[13px] text-muted-foreground">
<span>alex@acme.co, priya@acme.co</span>
<span>eng-leads@acme.co</span>
</div>
</InboxItem>
<AnimatePresence>
{reportApproved ? (
<motion.div
key="report-receipt"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25, ease: EASE_OUT }}
className="px-4 py-3"
>
<ActionReceipt
title="Report sent"
scope="3 recipients · usage-weekly.pdf"
timestamp="just now"
onUndo={() => {}}
/>
</motion.div>
) : null}
</AnimatePresence>
<InboxItem
icon={items[1].icon}
source={items[1].source}
title={items[1].title}
description={items[1].description}
risk={items[1].risk}
expires={items[1].status === "pending" ? items[1].expires : undefined}
status={items[1].status}
resolution={resolutionFor(items[1].status)}
onApprove={() => resolve("migrate", "approved")}
onDeny={() => resolve("migrate", "denied")}
>
<ActionReceipt
title="Planned changes"
scope="db: production/users, sessions"
added={14}
removed={3}
/>
</InboxItem>
<InboxItem
icon={items[2].icon}
source={items[2].source}
title={items[2].title}
description={items[2].description}
risk={items[2].risk}
status={items[2].status}
resolution={resolutionFor(items[2].status)}
onApprove={() => resolve("cleanup", "approved")}
onDeny={() => resolve("cleanup", "denied")}
/>
</AgentInbox>
</div>
</div>
);
}
"use client";
// ui-lab-ten.vercel.app/components/blocks/agent-inbox
import { Check, ChevronRight } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
createContext,
type ReactNode,
useContext,
useLayoutEffect,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type InboxRisk = "low" | "medium" | "high";
export type InboxStatus = "pending" | "approved" | "denied" | "expired";
export type AgentInboxVariant = "card" | "quiet";
const AgentInboxVariantContext = createContext<AgentInboxVariant>("card");
interface InboxActionButtonProps {
variant?: "ghost" | "primary";
onClick?: () => void;
children?: ReactNode;
}
/** Self-contained ghost/primary button for `InboxItem`'s approve/deny row — deliberately not imported from `agent-thread` so this block distributes independently. */
function InboxActionButton({ variant = "ghost", onClick, children }: InboxActionButtonProps) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex h-7 items-center rounded-lg text-sm transition-colors",
variant === "ghost" &&
"px-2 text-muted-foreground hover:bg-[var(--wb-hover)] hover:text-foreground",
variant === "primary" && "bg-foreground px-3 text-background transition-opacity hover:opacity-85",
)}
>
{children}
</button>
);
}
interface InboxCountBadgeProps {
count: number;
}
/** Small pill in `AgentInbox`'s header — the digits crossfade with a short y-shift on change, matching `ThreadBranchSwitcher`'s readout. */
function InboxCountBadge({ count }: InboxCountBadgeProps) {
const reduce = useReducedMotion() ?? false;
return (
<span className="relative inline-flex h-5 min-w-5 items-center justify-center overflow-hidden rounded-full bg-[var(--wb-accent)]/15 px-1.5 text-xs font-medium text-[var(--wb-accent)] tabular-nums">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={count}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
transition={{ duration: 0.15, ease: EASE_OUT }}
className="absolute inset-0 flex items-center justify-center"
>
{count}
</motion.span>
</AnimatePresence>
</span>
);
}
export interface AgentInboxProps {
/** Card keeps the approval-queue shell; quiet becomes a flat semantic list. */
variant?: AgentInboxVariant;
title?: ReactNode;
/** Pending-count badge shown next to the title; omit to hide it entirely. */
count?: number;
/** Trailing slot for the caller's own controls, e.g. a "Clear" button. */
action?: ReactNode;
className?: string;
/** `InboxItem`s (or any other row, e.g. a standalone `ActionReceipt`) — hairline-separated top to bottom. */
children?: ReactNode;
}
/**
* Approval-queue container — a hairline-ringed card (the same CSS-variable
* box-shadow technique as `ThreadCard`) with a header row (title, optional
* count badge, trailing action) above a stack of rows. Rows are rendered
* directly as `children` and separated by a hairline via a descendant
* selector, so any row shape (an `InboxItem`, a bare `ActionReceipt`) drops in
* cleanly without the container needing to know its type.
*/
export function AgentInbox({
variant = "card",
title,
count,
action,
className,
children,
}: AgentInboxProps) {
const quiet = variant === "quiet";
return (
<AgentInboxVariantContext.Provider value={variant}>
<div
data-slot="agent-inbox"
data-variant={variant}
style={quiet ? undefined : { boxShadow: "0 0 0 0.5px var(--wb-hairline)" }}
className={cn(
"flex flex-col overflow-hidden",
quiet &&
"border-[var(--wb-border-subtle)] border-y-[0.5px] bg-transparent",
!quiet && "rounded-2xl bg-[var(--wb-surface)]",
className,
)}
>
{!quiet || title || count !== undefined || action ? (
<div
className={cn(
"flex shrink-0 items-center gap-2 border-[var(--wb-divider)] border-b-[0.5px]",
quiet ? "h-9 px-0" : "h-11 px-4",
)}
>
{title ? <span className="text-sm font-medium">{title}</span> : null}
{count !== undefined ? <InboxCountBadge count={count} /> : null}
{action ? <div className="ml-auto shrink-0">{action}</div> : null}
</div>
) : null}
<div
data-slot="agent-inbox-items"
className="flex flex-col [&>*+*]:border-[var(--wb-divider)] [&>*+*]:border-t-[0.5px]"
>
{children}
</div>
</div>
</AgentInboxVariantContext.Provider>
);
}
export interface InboxRiskBadgeProps {
risk: InboxRisk;
className?: string;
}
/** Uppercase risk pill — low is neutral, medium is amber, high is red, matching the library's warning/danger hues. */
export function InboxRiskBadge({ risk, className }: InboxRiskBadgeProps) {
return (
<span
className={cn(
"inline-flex h-4.5 items-center rounded-full px-1.5 text-[10px] font-medium uppercase tracking-wide",
risk === "low" && "bg-[var(--wb-inset-strong)] text-muted-foreground",
risk === "medium" && "bg-[var(--wb-warning-surface)]/10 text-[var(--wb-warning)]",
risk === "high" && "bg-[var(--wb-danger-surface)]/10 text-[var(--wb-danger)]",
className,
)}
>
{risk}
</span>
);
}
export interface InboxItemProps {
/** Matches the parent AgentInbox variant; quiet renders a compact flat row. */
variant?: AgentInboxVariant;
/** 16px leading icon, e.g. `<Mail className="h-4 w-4" />`. */
icon?: ReactNode;
/** Requesting agent, e.g. "deploy-agent". */
source?: ReactNode;
title: ReactNode;
description?: ReactNode;
risk?: InboxRisk;
/** Trailing countdown readout, e.g. "expires in 2h". */
expires?: ReactNode;
status?: InboxStatus;
onApprove?: () => void;
onDeny?: () => void;
approveLabel?: ReactNode;
denyLabel?: ReactNode;
/** Replaces the approve/deny row once `status` is no longer "pending". */
resolution?: ReactNode;
className?: string;
/** Optional detail region (e.g. a command preview or an `ActionReceipt`) revealed by a "Details" toggle. */
children?: ReactNode;
}
/**
* One queued approval request. While `pending`, a ghost Deny and a primary
* Approve button sit at the trailing edge; once resolved, `resolution`
* replaces them and the rest of the row's content dims to `opacity-70` (the
* resolution line stays at full opacity). The optional `children` region is
* measured with a `ResizeObserver` and tweened open/closed (`EASE_OUT`,
* 0.25s), shown instantly under `useReducedMotion()` — the same idiom as
* `ThreadCollapse`. The root is a `motion.div` with `layout` so the height
* change on resolution settles smoothly instead of snapping.
*/
export function InboxItem({
variant: variantProp,
icon,
source,
title,
description,
risk,
expires,
status = "pending",
onApprove,
onDeny,
approveLabel = "Approve",
denyLabel = "Deny",
resolution,
className,
children,
}: InboxItemProps) {
const reduce = useReducedMotion() ?? false;
const inheritedVariant = useContext(AgentInboxVariantContext);
const variant = variantProp ?? inheritedVariant;
const [detailsOpen, setDetailsOpen] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState(0);
const pending = status === "pending";
const hasDetails = children !== undefined && children !== null;
useLayoutEffect(() => {
const node = contentRef.current;
if (!node) return;
const update = () => setContentHeight(node.offsetHeight);
update();
const observer = new ResizeObserver(update);
observer.observe(node);
return () => observer.disconnect();
}, []);
if (variant === "quiet") {
return (
<motion.div
layout={!reduce}
transition={{ layout: SPRING_LAYOUT }}
data-slot="agent-inbox-item"
data-variant="quiet"
className={cn(
"flex min-h-12 flex-wrap items-center gap-3 px-0 py-2.5 text-sm",
className,
)}
>
<div className="flex min-w-0 basis-[34%] items-center gap-2 font-medium">
{icon ? (
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
{icon}
</span>
) : null}
<span className="truncate">{title}</span>
</div>
<div className="min-w-0 flex-1 text-muted-foreground">
{source ? <div className="truncate text-[11px]">{source}</div> : null}
{description ? (
<div className="truncate text-[12px]">{description}</div>
) : null}
</div>
<div className="ml-auto flex shrink-0 items-center gap-1.5">
{risk ? <InboxRiskBadge risk={risk} /> : null}
{expires ? (
<span className="text-[11px] text-muted-foreground/70 tabular-nums">
{expires}
</span>
) : null}
{pending ? (
<>
<InboxActionButton variant="ghost" onClick={onDeny}>
{denyLabel}
</InboxActionButton>
<InboxActionButton variant="primary" onClick={onApprove}>
{approveLabel}
</InboxActionButton>
</>
) : resolution !== undefined ? (
<span
className={cn(
"text-[12px]",
status === "approved" && "text-[var(--wb-success)]",
status === "denied" && "text-muted-foreground",
status === "expired" && "text-muted-foreground/60 italic",
)}
>
{resolution}
</span>
) : status === "approved" ? (
<Check className="h-4 w-4 text-[var(--wb-success)]" />
) : null}
</div>
{hasDetails ? (
<div className="w-full pl-6">
<button
type="button"
aria-expanded={detailsOpen}
onClick={() => setDetailsOpen((open) => !open)}
className="-mx-1 inline-flex items-center gap-1 rounded-md px-1 text-muted-foreground text-xs transition-colors hover:bg-[var(--wb-hover)]"
>
Details
<ChevronRight className="h-3 w-3" />
</button>
{detailsOpen ? <div className="pt-2">{children}</div> : null}
</div>
) : null}
</motion.div>
);
}
return (
<motion.div
layout={!reduce}
transition={{ layout: SPRING_LAYOUT }}
data-slot="agent-inbox-item"
data-variant="card"
className={cn("px-4 py-3", className)}
>
<div className={cn(!pending && "opacity-70")}>
<div className="flex items-center gap-2">
{icon ? (
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
{icon}
</span>
) : null}
{source ? <span className="text-xs text-muted-foreground">{source}</span> : null}
{risk ? <InboxRiskBadge risk={risk} /> : null}
{expires ? (
<span className="ml-auto text-xs text-muted-foreground/70 tabular-nums">{expires}</span>
) : null}
</div>
<div className="mt-1.5 font-medium text-sm">{title}</div>
{description ? <div className="text-[13px] text-muted-foreground">{description}</div> : null}
{hasDetails ? (
<>
<button
type="button"
aria-expanded={detailsOpen}
onClick={() => setDetailsOpen((open) => !open)}
className="-mx-1 mt-1 inline-flex items-center gap-1 self-start rounded-md px-1 text-muted-foreground text-xs transition-colors hover:bg-[var(--wb-hover)]"
>
Details
<motion.span
aria-hidden
className="flex text-muted-foreground/60"
animate={reduce ? undefined : { rotate: detailsOpen ? 90 : 0 }}
style={reduce ? { transform: detailsOpen ? "rotate(90deg)" : "rotate(0deg)" } : undefined}
transition={reduce ? undefined : { duration: 0.2, ease: EASE_OUT }}
>
<ChevronRight className="h-3 w-3" />
</motion.span>
</button>
<motion.div
initial={false}
animate={reduce ? undefined : { height: detailsOpen ? contentHeight : 0 }}
transition={{ duration: 0.25, ease: EASE_OUT }}
className={cn("overflow-hidden", reduce && (detailsOpen ? "h-auto" : "h-0"))}
>
<div ref={contentRef} className="pt-2">
{children}
</div>
</motion.div>
</>
) : null}
</div>
{pending ? (
<div className="mt-2 flex items-center gap-1.5">
<InboxActionButton variant="ghost" onClick={onDeny}>
{denyLabel}
</InboxActionButton>
<InboxActionButton variant="primary" onClick={onApprove}>
{approveLabel}
</InboxActionButton>
</div>
) : resolution !== undefined ? (
<div
className={cn(
"mt-2 text-[13px]",
status === "approved" && "text-[var(--wb-success)]",
status === "denied" && "text-muted-foreground",
status === "expired" && "text-muted-foreground/60 italic",
)}
>
{resolution}
</div>
) : null}
</motion.div>
);
}
export interface ActionReceiptProps {
/** 16px icon, defaults to a `Check` in the library's success green. */
icon?: ReactNode;
title: ReactNode;
/** Sub-line describing what was acted on, e.g. "db: production/users, sessions". */
scope?: ReactNode;
added?: number;
removed?: number;
timestamp?: ReactNode;
onUndo?: () => void;
undoLabel?: ReactNode;
className?: string;
/** Optional extra content, e.g. a list of affected file rows. */
children?: ReactNode;
}
/**
* Operation receipt — the record of a completed action, usable standalone or
* dropped into an `InboxItem`'s detail region (e.g. a "planned changes"
* preview before approval, or a completion summary after). A hairline-ringed
* surface (the `ThreadCard` box-shadow technique) rather than `AgentInbox`'s
* full card chrome, since it's meant to nest inside other rows.
*/
export function ActionReceipt({
icon,
title,
scope,
added,
removed,
timestamp,
onUndo,
undoLabel = "Undo",
className,
children,
}: ActionReceiptProps) {
return (
<div
style={{ boxShadow: "0 0 0 0.5px var(--wb-hairline)" }}
className={cn(
"rounded-xl bg-[var(--wb-inset-faint)] p-3",
className,
)}
>
<div className="flex items-center gap-2">
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-[var(--wb-success)]">
{icon ?? <Check className="h-4 w-4" />}
</span>
<span className="font-medium text-sm">{title}</span>
{timestamp ? (
<span className="ml-auto text-muted-foreground/70 text-xs">{timestamp}</span>
) : null}
</div>
{scope ? <div className="mt-1 text-[13px] text-muted-foreground">{scope}</div> : null}
{added !== undefined || removed !== undefined ? (
<div className="mt-1 flex gap-1.5 text-[13px]">
{added !== undefined ? (
<span className="text-[var(--wb-success)]">+{added}</span>
) : null}
{removed !== undefined ? (
<span className="text-[var(--wb-danger)]">−{removed}</span>
) : null}
</div>
) : null}
{children}
{onUndo ? (
<div className="mt-2">
<button
type="button"
onClick={onUndo}
className="flex h-6 items-center rounded-md px-2 text-muted-foreground text-xs transition-colors hover:bg-[var(--wb-hover)] hover:text-foreground"
>
{undoLabel}
</button>
</div>
) : null}
</div>
);
}
安装
用 shadcn CLI 添加,或手动复制源码。
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// ui-lab-ten.vercel.app/components/blocks/agent-inbox
import { Check, ChevronRight } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
createContext,
type ReactNode,
useContext,
useLayoutEffect,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type InboxRisk = "low" | "medium" | "high";
export type InboxStatus = "pending" | "approved" | "denied" | "expired";
export type AgentInboxVariant = "card" | "quiet";
const AgentInboxVariantContext = createContext<AgentInboxVariant>("card");
interface InboxActionButtonProps {
variant?: "ghost" | "primary";
onClick?: () => void;
children?: ReactNode;
}
/** Self-contained ghost/primary button for `InboxItem`'s approve/deny row — deliberately not imported from `agent-thread` so this block distributes independently. */
function InboxActionButton({ variant = "ghost", onClick, children }: InboxActionButtonProps) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex h-7 items-center rounded-lg text-sm transition-colors",
variant === "ghost" &&
"px-2 text-muted-foreground hover:bg-[var(--wb-hover)] hover:text-foreground",
variant === "primary" && "bg-foreground px-3 text-background transition-opacity hover:opacity-85",
)}
>
{children}
</button>
);
}
interface InboxCountBadgeProps {
count: number;
}
/** Small pill in `AgentInbox`'s header — the digits crossfade with a short y-shift on change, matching `ThreadBranchSwitcher`'s readout. */
function InboxCountBadge({ count }: InboxCountBadgeProps) {
const reduce = useReducedMotion() ?? false;
return (
<span className="relative inline-flex h-5 min-w-5 items-center justify-center overflow-hidden rounded-full bg-[var(--wb-accent)]/15 px-1.5 text-xs font-medium text-[var(--wb-accent)] tabular-nums">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={count}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
transition={{ duration: 0.15, ease: EASE_OUT }}
className="absolute inset-0 flex items-center justify-center"
>
{count}
</motion.span>
</AnimatePresence>
</span>
);
}
export interface AgentInboxProps {
/** Card keeps the approval-queue shell; quiet becomes a flat semantic list. */
variant?: AgentInboxVariant;
title?: ReactNode;
/** Pending-count badge shown next to the title; omit to hide it entirely. */
count?: number;
/** Trailing slot for the caller's own controls, e.g. a "Clear" button. */
action?: ReactNode;
className?: string;
/** `InboxItem`s (or any other row, e.g. a standalone `ActionReceipt`) — hairline-separated top to bottom. */
children?: ReactNode;
}
/**
* Approval-queue container — a hairline-ringed card (the same CSS-variable
* box-shadow technique as `ThreadCard`) with a header row (title, optional
* count badge, trailing action) above a stack of rows. Rows are rendered
* directly as `children` and separated by a hairline via a descendant
* selector, so any row shape (an `InboxItem`, a bare `ActionReceipt`) drops in
* cleanly without the container needing to know its type.
*/
export function AgentInbox({
variant = "card",
title,
count,
action,
className,
children,
}: AgentInboxProps) {
const quiet = variant === "quiet";
return (
<AgentInboxVariantContext.Provider value={variant}>
<div
data-slot="agent-inbox"
data-variant={variant}
style={quiet ? undefined : { boxShadow: "0 0 0 0.5px var(--wb-hairline)" }}
className={cn(
"flex flex-col overflow-hidden",
quiet &&
"border-[var(--wb-border-subtle)] border-y-[0.5px] bg-transparent",
!quiet && "rounded-2xl bg-[var(--wb-surface)]",
className,
)}
>
{!quiet || title || count !== undefined || action ? (
<div
className={cn(
"flex shrink-0 items-center gap-2 border-[var(--wb-divider)] border-b-[0.5px]",
quiet ? "h-9 px-0" : "h-11 px-4",
)}
>
{title ? <span className="text-sm font-medium">{title}</span> : null}
{count !== undefined ? <InboxCountBadge count={count} /> : null}
{action ? <div className="ml-auto shrink-0">{action}</div> : null}
</div>
) : null}
<div
data-slot="agent-inbox-items"
className="flex flex-col [&>*+*]:border-[var(--wb-divider)] [&>*+*]:border-t-[0.5px]"
>
{children}
</div>
</div>
</AgentInboxVariantContext.Provider>
);
}
export interface InboxRiskBadgeProps {
risk: InboxRisk;
className?: string;
}
/** Uppercase risk pill — low is neutral, medium is amber, high is red, matching the library's warning/danger hues. */
export function InboxRiskBadge({ risk, className }: InboxRiskBadgeProps) {
return (
<span
className={cn(
"inline-flex h-4.5 items-center rounded-full px-1.5 text-[10px] font-medium uppercase tracking-wide",
risk === "low" && "bg-[var(--wb-inset-strong)] text-muted-foreground",
risk === "medium" && "bg-[var(--wb-warning-surface)]/10 text-[var(--wb-warning)]",
risk === "high" && "bg-[var(--wb-danger-surface)]/10 text-[var(--wb-danger)]",
className,
)}
>
{risk}
</span>
);
}
export interface InboxItemProps {
/** Matches the parent AgentInbox variant; quiet renders a compact flat row. */
variant?: AgentInboxVariant;
/** 16px leading icon, e.g. `<Mail className="h-4 w-4" />`. */
icon?: ReactNode;
/** Requesting agent, e.g. "deploy-agent". */
source?: ReactNode;
title: ReactNode;
description?: ReactNode;
risk?: InboxRisk;
/** Trailing countdown readout, e.g. "expires in 2h". */
expires?: ReactNode;
status?: InboxStatus;
onApprove?: () => void;
onDeny?: () => void;
approveLabel?: ReactNode;
denyLabel?: ReactNode;
/** Replaces the approve/deny row once `status` is no longer "pending". */
resolution?: ReactNode;
className?: string;
/** Optional detail region (e.g. a command preview or an `ActionReceipt`) revealed by a "Details" toggle. */
children?: ReactNode;
}
/**
* One queued approval request. While `pending`, a ghost Deny and a primary
* Approve button sit at the trailing edge; once resolved, `resolution`
* replaces them and the rest of the row's content dims to `opacity-70` (the
* resolution line stays at full opacity). The optional `children` region is
* measured with a `ResizeObserver` and tweened open/closed (`EASE_OUT`,
* 0.25s), shown instantly under `useReducedMotion()` — the same idiom as
* `ThreadCollapse`. The root is a `motion.div` with `layout` so the height
* change on resolution settles smoothly instead of snapping.
*/
export function InboxItem({
variant: variantProp,
icon,
source,
title,
description,
risk,
expires,
status = "pending",
onApprove,
onDeny,
approveLabel = "Approve",
denyLabel = "Deny",
resolution,
className,
children,
}: InboxItemProps) {
const reduce = useReducedMotion() ?? false;
const inheritedVariant = useContext(AgentInboxVariantContext);
const variant = variantProp ?? inheritedVariant;
const [detailsOpen, setDetailsOpen] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState(0);
const pending = status === "pending";
const hasDetails = children !== undefined && children !== null;
useLayoutEffect(() => {
const node = contentRef.current;
if (!node) return;
const update = () => setContentHeight(node.offsetHeight);
update();
const observer = new ResizeObserver(update);
observer.observe(node);
return () => observer.disconnect();
}, []);
if (variant === "quiet") {
return (
<motion.div
layout={!reduce}
transition={{ layout: SPRING_LAYOUT }}
data-slot="agent-inbox-item"
data-variant="quiet"
className={cn(
"flex min-h-12 flex-wrap items-center gap-3 px-0 py-2.5 text-sm",
className,
)}
>
<div className="flex min-w-0 basis-[34%] items-center gap-2 font-medium">
{icon ? (
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
{icon}
</span>
) : null}
<span className="truncate">{title}</span>
</div>
<div className="min-w-0 flex-1 text-muted-foreground">
{source ? <div className="truncate text-[11px]">{source}</div> : null}
{description ? (
<div className="truncate text-[12px]">{description}</div>
) : null}
</div>
<div className="ml-auto flex shrink-0 items-center gap-1.5">
{risk ? <InboxRiskBadge risk={risk} /> : null}
{expires ? (
<span className="text-[11px] text-muted-foreground/70 tabular-nums">
{expires}
</span>
) : null}
{pending ? (
<>
<InboxActionButton variant="ghost" onClick={onDeny}>
{denyLabel}
</InboxActionButton>
<InboxActionButton variant="primary" onClick={onApprove}>
{approveLabel}
</InboxActionButton>
</>
) : resolution !== undefined ? (
<span
className={cn(
"text-[12px]",
status === "approved" && "text-[var(--wb-success)]",
status === "denied" && "text-muted-foreground",
status === "expired" && "text-muted-foreground/60 italic",
)}
>
{resolution}
</span>
) : status === "approved" ? (
<Check className="h-4 w-4 text-[var(--wb-success)]" />
) : null}
</div>
{hasDetails ? (
<div className="w-full pl-6">
<button
type="button"
aria-expanded={detailsOpen}
onClick={() => setDetailsOpen((open) => !open)}
className="-mx-1 inline-flex items-center gap-1 rounded-md px-1 text-muted-foreground text-xs transition-colors hover:bg-[var(--wb-hover)]"
>
Details
<ChevronRight className="h-3 w-3" />
</button>
{detailsOpen ? <div className="pt-2">{children}</div> : null}
</div>
) : null}
</motion.div>
);
}
return (
<motion.div
layout={!reduce}
transition={{ layout: SPRING_LAYOUT }}
data-slot="agent-inbox-item"
data-variant="card"
className={cn("px-4 py-3", className)}
>
<div className={cn(!pending && "opacity-70")}>
<div className="flex items-center gap-2">
{icon ? (
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
{icon}
</span>
) : null}
{source ? <span className="text-xs text-muted-foreground">{source}</span> : null}
{risk ? <InboxRiskBadge risk={risk} /> : null}
{expires ? (
<span className="ml-auto text-xs text-muted-foreground/70 tabular-nums">{expires}</span>
) : null}
</div>
<div className="mt-1.5 font-medium text-sm">{title}</div>
{description ? <div className="text-[13px] text-muted-foreground">{description}</div> : null}
{hasDetails ? (
<>
<button
type="button"
aria-expanded={detailsOpen}
onClick={() => setDetailsOpen((open) => !open)}
className="-mx-1 mt-1 inline-flex items-center gap-1 self-start rounded-md px-1 text-muted-foreground text-xs transition-colors hover:bg-[var(--wb-hover)]"
>
Details
<motion.span
aria-hidden
className="flex text-muted-foreground/60"
animate={reduce ? undefined : { rotate: detailsOpen ? 90 : 0 }}
style={reduce ? { transform: detailsOpen ? "rotate(90deg)" : "rotate(0deg)" } : undefined}
transition={reduce ? undefined : { duration: 0.2, ease: EASE_OUT }}
>
<ChevronRight className="h-3 w-3" />
</motion.span>
</button>
<motion.div
initial={false}
animate={reduce ? undefined : { height: detailsOpen ? contentHeight : 0 }}
transition={{ duration: 0.25, ease: EASE_OUT }}
className={cn("overflow-hidden", reduce && (detailsOpen ? "h-auto" : "h-0"))}
>
<div ref={contentRef} className="pt-2">
{children}
</div>
</motion.div>
</>
) : null}
</div>
{pending ? (
<div className="mt-2 flex items-center gap-1.5">
<InboxActionButton variant="ghost" onClick={onDeny}>
{denyLabel}
</InboxActionButton>
<InboxActionButton variant="primary" onClick={onApprove}>
{approveLabel}
</InboxActionButton>
</div>
) : resolution !== undefined ? (
<div
className={cn(
"mt-2 text-[13px]",
status === "approved" && "text-[var(--wb-success)]",
status === "denied" && "text-muted-foreground",
status === "expired" && "text-muted-foreground/60 italic",
)}
>
{resolution}
</div>
) : null}
</motion.div>
);
}
export interface ActionReceiptProps {
/** 16px icon, defaults to a `Check` in the library's success green. */
icon?: ReactNode;
title: ReactNode;
/** Sub-line describing what was acted on, e.g. "db: production/users, sessions". */
scope?: ReactNode;
added?: number;
removed?: number;
timestamp?: ReactNode;
onUndo?: () => void;
undoLabel?: ReactNode;
className?: string;
/** Optional extra content, e.g. a list of affected file rows. */
children?: ReactNode;
}
/**
* Operation receipt — the record of a completed action, usable standalone or
* dropped into an `InboxItem`'s detail region (e.g. a "planned changes"
* preview before approval, or a completion summary after). A hairline-ringed
* surface (the `ThreadCard` box-shadow technique) rather than `AgentInbox`'s
* full card chrome, since it's meant to nest inside other rows.
*/
export function ActionReceipt({
icon,
title,
scope,
added,
removed,
timestamp,
onUndo,
undoLabel = "Undo",
className,
children,
}: ActionReceiptProps) {
return (
<div
style={{ boxShadow: "0 0 0 0.5px var(--wb-hairline)" }}
className={cn(
"rounded-xl bg-[var(--wb-inset-faint)] p-3",
className,
)}
>
<div className="flex items-center gap-2">
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-[var(--wb-success)]">
{icon ?? <Check className="h-4 w-4" />}
</span>
<span className="font-medium text-sm">{title}</span>
{timestamp ? (
<span className="ml-auto text-muted-foreground/70 text-xs">{timestamp}</span>
) : null}
</div>
{scope ? <div className="mt-1 text-[13px] text-muted-foreground">{scope}</div> : null}
{added !== undefined || removed !== undefined ? (
<div className="mt-1 flex gap-1.5 text-[13px]">
{added !== undefined ? (
<span className="text-[var(--wb-success)]">+{added}</span>
) : null}
{removed !== undefined ? (
<span className="text-[var(--wb-danger)]">−{removed}</span>
) : null}
</div>
) : null}
{children}
{onUndo ? (
<div className="mt-2">
<button
type="button"
onClick={onUndo}
className="flex h-6 items-center rounded-md px-2 text-muted-foreground text-xs transition-colors hover:bg-[var(--wb-hover)] hover:text-foreground"
>
{undoLabel}
</button>
</div>
) : null}
</div>
);
}
API 参考
AgentInbox
variant?"card" | "quiet"Card keeps the approval-queue shell; quiet becomes a flat semantic list.
cardtitle?ReactNode—count?numberPending-count badge shown next to the title; omit to hide it entirely.
—action?ReactNodeTrailing slot for the caller's own controls, e.g. a "Clear" button.
—className?string—children?ReactNode`InboxItem`s (or any other row, e.g. a standalone `ActionReceipt`) — hairline-separated top to bottom.
—InboxRiskBadge
risk"medium" | "low" | "high"—className?string—InboxItem
variant?"card" | "quiet"Matches the parent AgentInbox variant; quiet renders a compact flat row.
—icon?ReactNode16px leading icon, e.g. `<Mail className="h-4 w-4" />`.
—source?ReactNodeRequesting agent, e.g. "deploy-agent".
—titleReactNode—description?ReactNode—risk?"medium" | "low" | "high"—expires?ReactNodeTrailing countdown readout, e.g. "expires in 2h".
—status?"pending" | "approved" | "denied" | "expired"pendingonApprove?(() => void)—onDeny?(() => void)—approveLabel?ReactNodeApprovedenyLabel?ReactNodeDenyresolution?ReactNodeReplaces the approve/deny row once `status` is no longer "pending".
—className?string—children?ReactNodeOptional detail region (e.g. a command preview or an `ActionReceipt`) revealed by a "Details" toggle.
—ActionReceipt
icon?ReactNode16px icon, defaults to a `Check` in the library's success green.
—titleReactNode—scope?ReactNodeSub-line describing what was acted on, e.g. "db: production/users, sessions".
—added?number—removed?number—timestamp?ReactNode—onUndo?(() => void)—undoLabel?ReactNodeUndoclassName?string—children?ReactNodeOptional extra content, e.g. a list of affected file rows.
—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.