A desk for reviewing what agents made: items with the agent's confidence, a summary and the thing itself, Approve or Request changes with a comment, J, K, A and R keys, and decisions undone with the reason if saving fails.
Review desk
3 to review · 1 approved
To review: Release notes for version 2.4
Writer agent · 91% sure
To review: Refund of $340.00 to Maria Lopez
Payments agent · 48% sure
To review: Reply to the press enquiry from The Daily Ledger
Support agent · 77% sure
Approved: Weekly usage report for the leadership team
Analyst agent · 95% sure
Release notes for version 2.4To review
Writer agent · Changelog, 240 words
Eight changes grouped into New, Improved and Fixed, written from the merged pull requests.
### New
Added: - Export any report as a PDF from the share menu.
Added: - Filters now remember your last choice.
### Fixed
Removed: - Fixed a bug.
Added: - Fixed totals rounding on invoices over $10,000.
J and K move, A approves, R asks for changes.
"use client"
import * as React from "react"
import { ReviewCenter, type ReviewItem } from "@/components/ballmac/review-center"
function Diff({ lines }: { lines: [string, string][] }) {
return (
<div dir="ltr" className="grid gap-px overflow-hidden rounded-md border bg-border font-mono text-xs">
{lines.map(([kind, text], i) => (
<p key={i} className={kind === "+" ? "bg-chart-2/10 px-3 py-1" : kind === "-" ? "bg-destructive/10 px-3 py-1" : "bg-card px-3 py-1 text-muted-foreground"}>
<span aria-hidden="true" className="me-2 select-none">{kind === " " ? " " : kind}</span>
<span className="sr-only">{kind === "+" ? "Added: " : kind === "-" ? "Removed: " : ""}</span>
{text}
</p>
))}
</div>
)
}
const items: ReviewItem[] = [
{ id: "1", title: "Release notes for version 2.4", agent: "Writer agent", meta: "Changelog, 240 words", confidence: 0.91, status: "pending", summary: "Eight changes grouped into New, Improved and Fixed, written from the merged pull requests.", preview: <Diff lines={[[" ", "### New"], ["+", "- Export any report as a PDF from the share menu."], ["+", "- Filters now remember your last choice."], [" ", "### Fixed"], ["-", "- Fixed a bug."], ["+", "- Fixed totals rounding on invoices over $10,000."]]} /> },
{ id: "2", title: "Refund of $340.00 to Maria Lopez", agent: "Payments agent", meta: "Order A-9914", confidence: 0.48, status: "pending", summary: "The order arrived damaged twice. The amount is above the $250 an agent may approve alone, and the policy allows it.", preview: <Diff lines={[[" ", "order A-9914"], [" ", "reason damaged on arrival (twice)"], ["+", "refund $340.00 to the original card"], ["-", "replacement not sent"]]} /> },
{ id: "3", title: "Reply to the press enquiry from The Daily Ledger", agent: "Support agent", meta: "Email draft", confidence: 0.77, status: "pending", summary: "A short holding reply that promises an answer by Friday and names no figures." },
{ id: "4", title: "Weekly usage report for the leadership team", agent: "Analyst agent", meta: "Report, 2 pages", confidence: 0.95, status: "approved", summary: "Usage is up 12% week on week, driven by the new import flow." },
]
export default function ReviewCenterDemo() {
return (
<div className="w-full max-w-4xl">
<ReviewCenter
items={items}
title="Review desk"
onApprove={() => new Promise((resolve) => setTimeout(resolve, 400))}
onRequestChanges={() => new Promise((resolve) => setTimeout(resolve, 400))}
/>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/review-centerInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/review-center.tsx// Ballmac UI: Review Center. https://ui.ballmac.com/components/review-center "use client" import * as React from "react" import { Check, CircleDashed, Loader2, MessageSquareWarning } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type ReviewStatus = "pending" | "approved" | "changes" type ReviewItem = { id: string title: string /** Which agent made it. */ agent?: string /** What it is and what it did, in a sentence or two. */ summary: string /** A line of context such as "Release notes, 240 words". */ meta?: string /** How sure the agent is, 0 to 1. Shown as a percentage and a bar. */ confidence?: number status: ReviewStatus /** What the reviewer asked to change, shown on an item that was sent back. */ feedback?: string /** The thing to review: a diff, a draft, a table. Anything you can render. */ preview?: React.ReactNode } type ReviewCenterProps = Omit<React.ComponentProps<"section">, "title" | "onSelect"> & { items: ReviewItem[] title?: string /** The selected item id. Controlled. */ selected?: string | null defaultSelected?: string | null onSelectedChange?: (id: string) => void /** Called to approve an item. Reject and the decision is taken back with the message. */ onApprove?: (id: string) => void | Promise<void> /** Called to send an item back with what should change. Reject and the decision is taken back with the message. */ onRequestChanges?: (id: string, comment: string) => void | Promise<void> } const STATUS = { pending: { icon: CircleDashed, label: "To review" }, approved: { icon: Check, label: "Approved" }, changes: { icon: MessageSquareWarning, label: "Sent back" } } as const /** * A desk for reviewing what agents produced: a list of items to look at, with how confident the agent was, and for the chosen one its summary, the thing itself * (a diff, a draft, a table: you pass it in) and two decisions, Approve and Request changes with a comment. It is built to be fast from the keyboard: J and K * (or the arrow keys) move, A approves, R asks for changes, and after a decision it moves to the next item still waiting. A decision is shown at once and * taken back with the reason if it cannot be saved. */ function ReviewCenter({ items, title, selected: selectedProp, defaultSelected, onSelectedChange, onApprove, onRequestChanges, className, ...props }: ReviewCenterProps) { const msg = useMessages() const id = React.useId() title ??= msg("review-center.title", "Review") const [local, setLocal] = React.useState<Record<string, { status: ReviewStatus; feedback?: string }>>({}) const [inner, setInner] = React.useState<string | null>(defaultSelected ?? items.find((i) => i.status === "pending")?.id ?? items[0]?.id ?? null) const selected = selectedProp !== undefined ? selectedProp : inner const [asking, setAsking] = React.useState(false) const [comment, setComment] = React.useState("") const [busy, setBusy] = React.useState(false) const [error, setError] = React.useState<string | null>(null) const [announce, setAnnounce] = React.useState("") const commentRef = React.useRef<HTMLTextAreaElement>(null) const optionRefs = React.useRef(new Map<string, HTMLDivElement>()) // New items from the parent replace what was decided here. const key = items.map((i) => `${i.id}:${i.status}`).join("|") React.useEffect(() => setLocal({}), [key]) const view = items.map((i) => ({ ...i, ...(local[i.id] ?? {}) })) const chosen = view.find((i) => i.id === selected) const counts = { pending: view.filter((i) => i.status === "pending").length, approved: view.filter((i) => i.status === "approved").length, changes: view.filter((i) => i.status === "changes").length } const word = (s: ReviewStatus) => msg(`review-center.status.${s}`, STATUS[s].label) function select(itemId: string, focus = false) { setAsking(false) setComment("") setError(null) if (selectedProp === undefined) setInner(itemId) onSelectedChange?.(itemId) if (focus) optionRefs.current.get(itemId)?.focus() } const step = (delta: number) => { const at = view.findIndex((i) => i.id === selected) const next = view[Math.min(view.length - 1, Math.max(0, at + delta))] if (next) select(next.id, true) } const nextPending = (from: string) => { const at = view.findIndex((i) => i.id === from) return [...view.slice(at + 1), ...view.slice(0, at)].find((i) => i.status === "pending") } async function decide(status: ReviewStatus, text?: string) { if (!chosen) return if (status === "changes" && !text?.trim()) { setError(msg("review-center.needComment", "Say what should change first.")) return } const before = local[chosen.id] setLocal((l) => ({ ...l, [chosen.id]: { status, feedback: text?.trim() } })) setBusy(true) setError(null) try { await (status === "approved" ? onApprove?.(chosen.id) : onRequestChanges?.(chosen.id, text!.trim())) setAnnounce(status === "approved" ? msg("review-center.approvedDone", "Approved: {title}", { title: chosen.title }) : msg("review-center.changesDone", "Sent back: {title}", { title: chosen.title })) setAsking(false) setComment("") const next = nextPending(chosen.id) if (next) select(next.id, true) } catch (e) { setLocal((l) => { const n = { ...l } if (before) n[chosen.id] = before else delete n[chosen.id] return n }) setError(e instanceof Error ? e.message : msg("review-center.failed", "The decision was not saved. Nothing was changed.")) } finally { setBusy(false) } } function onSectionKeyDown(e: React.KeyboardEvent) { const t = e.target as HTMLElement if (t.tagName === "TEXTAREA" || t.tagName === "INPUT" || t.tagName === "SELECT" || e.metaKey || e.ctrlKey || e.altKey) return if (e.key === "j" || e.key === "ArrowDown") step(1) else if (e.key === "k" || e.key === "ArrowUp") step(-1) else if (e.key === "Home") { const f = view[0]; if (f) select(f.id, true) } else if (e.key === "End") { const l = view[view.length - 1]; if (l) select(l.id, true) } else if (e.key === "a" && chosen && !busy && onApprove) void decide("approved") else if (e.key === "r" && chosen && !busy && onRequestChanges) { setAsking(true); setTimeout(() => commentRef.current?.focus(), 0) } else return e.preventDefault() } return ( <section data-slot="review-center" aria-labelledby={`${id}-t`} onKeyDown={onSectionKeyDown} className={cn("bg-card text-card-foreground overflow-hidden rounded-xl border", className)} {...props}> <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b px-4 py-3"> <h3 id={`${id}-t`} className="text-sm font-semibold">{title}</h3> <p className="text-xs tabular-nums text-muted-foreground"> {msg("review-center.toReview", "{count} to review", { count: counts.pending })} · {msg("review-center.approvedCount", "{count} approved", { count: counts.approved })} {counts.changes > 0 ? ` · ${msg("review-center.changesCount", "{count} sent back", { count: counts.changes })}` : ""} </p> </div> {items.length === 0 ? ( <p className="px-4 py-12 text-center text-sm text-muted-foreground">{msg("review-center.empty", "Nothing to review.")}</p> ) : ( <div className="grid md:grid-cols-[17rem_minmax(0,1fr)]"> <div role="listbox" aria-label={msg("review-center.listLabel", "Items to review")} className="max-h-[28rem] divide-y overflow-y-auto border-b md:border-e md:border-b-0"> {view.map((it) => { const S = STATUS[it.status] const on = it.id === selected return ( <div key={it.id} ref={(el) => { if (el) optionRefs.current.set(it.id, el) else optionRefs.current.delete(it.id) }} role="option" aria-selected={on} tabIndex={on || (!selected && it.id === view[0]?.id) ? 0 : -1} onClick={() => select(it.id)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); select(it.id) } }} className={cn("flex cursor-default items-start gap-2.5 px-3.5 py-3 text-sm outline-none transition-colors hover:bg-accent/40 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset motion-reduce:transition-none", on && "bg-accent/60")} > <S.icon aria-hidden="true" className={cn("mt-0.5 size-4 shrink-0", it.status === "pending" ? "text-muted-foreground" : "text-foreground")} /> <div className="min-w-0 flex-1"> <p className={cn("leading-snug text-pretty", it.status === "pending" ? "font-medium" : "text-muted-foreground")}><span className="sr-only">{word(it.status)}: </span>{it.title}</p> <p className="mt-0.5 truncate text-xs text-muted-foreground">{[it.agent, it.confidence !== undefined ? msg("review-center.confidence", "{percent}% sure", { percent: Math.round(it.confidence * 100) }) : undefined].filter(Boolean).join(" · ")}</p> </div> </div> ) })} </div> <div className="grid min-w-0 content-start gap-4 p-4 sm:p-5"> {chosen ? ( <> <div> <p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-base font-semibold tracking-tight"> {chosen.title} <span className={cn("rounded-full border px-2 py-px text-[11px] font-medium", chosen.status === "pending" ? "text-muted-foreground" : "border-foreground")}>{word(chosen.status)}</span> </p> <p className="mt-0.5 text-xs text-muted-foreground">{[chosen.agent, chosen.meta].filter(Boolean).join(" · ")}</p> </div> <p className="text-sm leading-relaxed text-pretty">{chosen.summary}</p> {chosen.confidence !== undefined && ( <div className="flex items-center gap-3 text-xs text-muted-foreground"> <div role="meter" aria-label={msg("review-center.confidenceMeter", "Agent confidence")} aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(chosen.confidence * 100)} aria-valuetext={msg("review-center.confidenceSpoken", "{percent}% sure", { percent: Math.round(chosen.confidence * 100) })} className="h-1.5 w-32 overflow-hidden rounded-full bg-muted"> <div className={cn("h-full rounded-full", chosen.confidence < 0.6 ? "bg-destructive/70" : "bg-foreground")} style={{ width: `${chosen.confidence * 100}%` }} /> </div> <span className={cn("tabular-nums", chosen.confidence < 0.6 && "font-medium text-destructive")}>{msg("review-center.confidence", "{percent}% sure", { percent: Math.round(chosen.confidence * 100) })}{chosen.confidence < 0.6 ? ` · ${msg("review-center.lowConfidence", "worth a close look")}` : ""}</span> </div> )} {chosen.preview && <div className="min-w-0 rounded-lg border bg-background/60 p-3.5 text-sm">{chosen.preview}</div>} {chosen.status === "changes" && chosen.feedback && <p className="rounded-lg bg-muted/60 px-3 py-2.5 text-sm"><span className="font-medium">{msg("review-center.youAsked", "You asked for:")}</span> {chosen.feedback}</p>} {asking && ( <div className="grid gap-2"> <label htmlFor={`${id}-c`} className="text-sm font-medium">{msg("review-center.commentLabel", "What should change?")}</label> <textarea ref={commentRef} id={`${id}-c`} rows={3} value={comment} onChange={(e) => { setComment(e.target.value); setError(null) }} onKeyDown={(e) => { if (e.key === "Escape") { e.stopPropagation(); setAsking(false) } else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); void decide("changes", comment) } }} aria-invalid={!!error} className="w-full resize-y rounded-lg border bg-background px-3 py-2.5 text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-[invalid=true]:border-destructive" /> </div> )} <div className="flex flex-wrap items-center gap-2"> {onApprove && ( <button type="button" disabled={busy || chosen.status === "approved"} onClick={() => void decide("approved")} className="inline-flex h-9 items-center gap-1.5 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground outline-none transition-colors hover:bg-primary/90 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none"> {busy ? <Loader2 aria-hidden="true" className="size-4 animate-spin" /> : <Check aria-hidden="true" className="size-4" />} {msg("review-center.approve", "Approve")} </button> )} {onRequestChanges && (asking ? ( <> <button type="button" disabled={busy} onClick={() => void decide("changes", comment)} className="inline-flex h-9 items-center rounded-full border bg-background px-4 text-sm font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50 motion-reduce:transition-none">{msg("review-center.sendBack", "Send it back")}</button> <button type="button" onClick={() => setAsking(false)} className="inline-flex h-9 items-center rounded-full px-3 text-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50">{msg("review-center.cancel", "Cancel")}</button> </> ) : ( <button type="button" disabled={busy} onClick={() => { setAsking(true); setTimeout(() => commentRef.current?.focus(), 0) }} className="inline-flex h-9 items-center rounded-full border bg-background px-4 text-sm font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50 motion-reduce:transition-none">{msg("review-center.requestChanges", "Request changes")}</button> ))} </div> {error && <p role="alert" className="text-sm font-medium text-destructive">{error}</p>} </> ) : ( <p className="py-8 text-center text-sm text-muted-foreground">{msg("review-center.hint", "Select an item to review it.")}</p> )} <p className="text-xs text-muted-foreground">{msg.rich("review-center.keys", "{j} and {k} move, {a} approves, {r} asks for changes.", { j: <kbd className="rounded border bg-muted px-1 font-mono">J</kbd>, k: <kbd className="rounded border bg-muted px-1 font-mono">K</kbd>, a: <kbd className="rounded border bg-muted px-1 font-mono">A</kbd>, r: <kbd className="rounded border bg-muted px-1 font-mono">R</kbd> })}</p> </div> </div> )} <p aria-live="polite" className="sr-only">{announce}</p> </section> ) } export { ReviewCenter, type ReviewCenterProps, type ReviewItem, type ReviewStatus }Update the import paths to match your project setup.
Usage
import { ReviewCenter } from "@/components/ballmac/review-center"The full example is in the Code tab above.
Examples
All reviewed
Review desk
0 to review · 1 approved · 1 sent back
Approved: Release notes for version 2.4
Writer agent · 91% sure
Sent back: Reply to the press enquiry
Support agent · 77% sure
Release notes for version 2.4Approved
Writer agent
Eight changes grouped into New, Improved and Fixed.
J and K move, A approves, R asks for changes.
"use client"
import { ReviewCenter } from "@/components/ballmac/review-center"
export default function ReviewCenterClear() {
return (
<div className="w-full max-w-4xl">
<ReviewCenter
title="Review desk"
items={[
{ id: "1", title: "Release notes for version 2.4", agent: "Writer agent", summary: "Eight changes grouped into New, Improved and Fixed.", status: "approved", confidence: 0.91 },
{ id: "2", title: "Reply to the press enquiry", agent: "Support agent", summary: "A short holding reply.", status: "changes", feedback: "Name the date we will answer by.", confidence: 0.77 },
]}
/>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
items* | ReviewItem[] | — |
title | string | — |
selectedThe selected item id. Controlled. | string | null | — |
defaultSelected | string | null | — |
onSelectedChange | (id: string) => void | — |
onApproveCalled to approve an item. Reject and the decision is taken back with the message. | (id: string) => void | Promise<void> | — |
onRequestChangesCalled to send an item back with what should change. Reject and the decision is taken back with the message. | (id: string, comment: string) => void | Promise<void> | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| List | A listbox; each option starts with its state in words, such as 'To review: …'; the selected option has aria-selected and one option is in the tab order |
| Keyboard | J and K or Up and Down move, Home and End jump, A approves and R asks for changes; the shortcuts are off while typing in the comment box, and Escape closes it |
| Decisions | Announced in a polite live region ('Approved: …'); a failed save is an alert and the decision is taken back |
| Confidence | A meter with a spoken value; below 60% it also says 'worth a close look' |
| Comment | A labelled textarea; an empty one is refused with an alert, and Ctrl or Command with Enter sends |
Use with AI
items is [{ id, title, agent?, summary, meta?, confidence?: 0-1, status: pending | approved | changes, feedback?, preview?: ReactNode }] (required); title, selected / defaultSelected / onSelectedChange. onApprove(id) and onRequestChanges(id, comment) may be async: the decision shows at once and is taken back with the reason if it rejects. Keys: J and K or the arrow keys move, A approves, R asks for changes, Home and End jump; after a decision it moves to the next item still waiting. Shortcuts are ignored while typing. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Review Center (@ballmac/review-center) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Reviewing drafts, decisions or changes an agent produced before they go out
- A queue where the reviewer's speed matters
Not for
- A single yes or no on one action; use approval-card
- A conversation to take over; use escalation-card
- Comparing alternative runs; use parallel-attempts
Registry JSON: https://ui.ballmac.com/r/review-center.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Agent 12: mission control
Mission control for a team of agents: spend, tokens, running agents and decisions waiting at the top, the delegation tree below, and an inbox of requests with Approve and Deny that report back honestly.
Approval Card
A human-in-the-loop confirmation for agent actions: what will happen, risk level in words, the exact command, Approve, Deny and Always allow, a timeout countdown and a recorded decision.
Escalation Card
The moment an AI agent hands a conversation to a person: who is waiting and for how long, why, how the customer seems to feel in words, what the agent understood, the last messages, an editable suggested reply, and Take over and Snooze.
Prompt Diff
Two versions of a prompt compared word by word: removed words struck through and added words underlined so colour is never the only cue, a count of each, {{variables}} marked, side by side or inline, and an optional Restore button.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.