Approvals Workflow Page
Approval queue for human-in-the-loop decisions. Engineers review proposed changes via inline diffs, add comments, and approve or reject with confirmation dialogs.
Table of contents
src/pages/approvals/index.tsxsrc/pages/approvals/components/pending-approvals-list.tsxsrc/pages/approvals/components/diff-viewer.tsxsrc/pages/approvals/components/approval-dialog.tsx
src/pages/approvals/index.tsx
Main approvals page with filter controls, approval cards, and integrated mutation handling.
import { useState, useMemo } from "react";
import {
CheckSquare,
AlertTriangle,
Filter,
} from "lucide-react";
import {
useApprovals,
useApproveSession,
useRejectSession,
} from "@/hooks/use-approvals";
import type { Approval, ApprovalStatus } from "@/types/approval";
import { PageHeader } from "@/components/layout/page-header";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/shared/empty-state";
import { showSuccess, showError } from "@/components/shared/toast-helpers";
import { PendingApprovalsList } from "./components/pending-approvals-list";
import { ApprovalDialog } from "./components/approval-dialog";
// ---------------------------------------------------------------------------
// Filter type
// ---------------------------------------------------------------------------
type ApprovalFilter = "all" | ApprovalStatus;
// ---------------------------------------------------------------------------
// Page component
// ---------------------------------------------------------------------------
export default function ApprovalsPage() {
const {
data: approvals,
isLoading,
isError,
error,
} = useApprovals();
const approveSession = useApproveSession();
const rejectSession = useRejectSession();
const [filter, setFilter] = useState<ApprovalFilter>("all");
const [expandedId, setExpandedId] = useState<string | null>(null);
const [dialogState, setDialogState] = useState<{
approval: Approval;
action: "approve" | "reject";
} | null>(null);
// Filter approvals
const filteredApprovals = useMemo(() => {
if (!approvals) return [];
if (filter === "all") return approvals;
return approvals.filter((a) => a.status === filter);
}, [approvals, filter]);
const pendingCount = useMemo(
() => approvals?.filter((a) => a.status === "pending").length ?? 0,
[approvals],
);
// Approve handler
async function handleApprove(approval: Approval, comment?: string) {
try {
await approveSession.mutateAsync({
sessionId: approval.sessionId,
comment,
});
showSuccess(`Approved: ${approval.title}`);
setDialogState(null);
} catch (err) {
showError(
err instanceof Error ? err.message : "Failed to approve session",
);
}
}
// Reject handler
async function handleReject(approval: Approval, reason: string) {
try {
await rejectSession.mutateAsync({
sessionId: approval.sessionId,
reason,
});
showSuccess(`Rejected: ${approval.title}`);
setDialogState(null);
} catch (err) {
showError(
err instanceof Error ? err.message : "Failed to reject session",
);
}
}
return (
<div className="space-y-6">
{/* ---- Page header ---- */}
<PageHeader
title="Approvals"
description="Review and approve or reject proposed changes from agent sessions"
>
<div className="flex items-center gap-3">
<Badge
variant={pendingCount > 0 ? "destructive" : "secondary"}
className="text-sm"
>
{pendingCount} Pending
</Badge>
{/* Filter select */}
<Select
value={filter}
onValueChange={(v) => setFilter(v as ApprovalFilter)}
>
<SelectTrigger className="w-[140px] h-8 text-xs gap-1">
<Filter className="h-3 w-3" />
<SelectValue placeholder="Filter" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
</SelectContent>
</Select>
</div>
</PageHeader>
{/* ---- Error state ---- */}
{isError && (
<Card className="border-destructive">
<CardContent className="py-8 text-center">
<AlertTriangle className="mx-auto h-10 w-10 text-destructive mb-3" />
<p className="text-sm text-muted-foreground">
Failed to load approvals.{" "}
{error instanceof Error ? error.message : "Unknown error."}
</p>
</CardContent>
</Card>
)}
{/* ---- Loading state ---- */}
{isLoading && (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-40 rounded-xl" />
))}
</div>
)}
{/* ---- Empty state ---- */}
{!isLoading && filteredApprovals.length === 0 && (
<EmptyState
icon={CheckSquare}
title={
filter === "pending"
? "No pending approvals"
: "No approvals found"
}
description={
filter === "pending"
? "All agent-proposed changes have been reviewed. Great work!"
: `No approvals match the "${filter}" filter.`
}
action={
filter !== "all" ? (
<Button
variant="outline"
size="sm"
onClick={() => setFilter("all")}
>
Show All
</Button>
) : undefined
}
/>
)}
{/* ---- Approvals list ---- */}
{!isLoading && filteredApprovals.length > 0 && (
<PendingApprovalsList
approvals={filteredApprovals}
expandedId={expandedId}
onToggleExpand={(id) =>
setExpandedId(expandedId === id ? null : id)
}
onApprove={(approval) =>
setDialogState({ approval, action: "approve" })
}
onReject={(approval) =>
setDialogState({ approval, action: "reject" })
}
/>
)}
{/* ---- Approval/Reject dialog ---- */}
{dialogState && (
<ApprovalDialog
approval={dialogState.approval}
action={dialogState.action}
isLoading={
approveSession.isPending || rejectSession.isPending
}
onConfirm={(commentOrReason) => {
if (dialogState.action === "approve") {
handleApprove(dialogState.approval, commentOrReason);
} else {
handleReject(dialogState.approval, commentOrReason ?? "");
}
}}
onCancel={() => setDialogState(null)}
/>
)}
</div>
);
}
src/pages/approvals/components/pending-approvals-list.tsx
Approval card list with expandable diffs, status badges, and action buttons.
import {
CheckCircle2,
XCircle,
Clock,
ChevronDown,
ChevronRight,
FileText,
FilePlus,
FileX,
FilePen,
ExternalLink,
} from "lucide-react";
import type { Approval } from "@/types/approval";
import {
Card,
CardHeader,
CardTitle,
CardContent,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { formatRelativeTime } from "@/lib/format";
import { AGENT_DISPLAY_NAMES } from "@/lib/constants";
import { DiffViewer } from "./diff-viewer";
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface PendingApprovalsListProps {
approvals: Approval[];
expandedId: string | null;
onToggleExpand: (id: string) => void;
onApprove: (approval: Approval) => void;
onReject: (approval: Approval) => void;
}
// ---------------------------------------------------------------------------
// Status configuration
// ---------------------------------------------------------------------------
const STATUS_CONFIG: Record<
Approval["status"],
{
icon: typeof Clock;
color: string;
bg: string;
badgeVariant: "default" | "secondary" | "destructive" | "outline";
label: string;
}
> = {
pending: {
icon: Clock,
color: "text-amber-600",
bg: "bg-amber-100 dark:bg-amber-900/30",
badgeVariant: "outline",
label: "Pending",
},
approved: {
icon: CheckCircle2,
color: "text-emerald-600",
bg: "bg-emerald-100 dark:bg-emerald-900/30",
badgeVariant: "default",
label: "Approved",
},
rejected: {
icon: XCircle,
color: "text-red-600",
bg: "bg-red-100 dark:bg-red-900/30",
badgeVariant: "destructive",
label: "Rejected",
},
};
// ---------------------------------------------------------------------------
// Change summary helpers
// ---------------------------------------------------------------------------
function changeTypeCounts(changes: Approval["changes"]) {
let added = 0;
let modified = 0;
let deleted = 0;
for (const c of changes) {
if (c.type === "add") added++;
else if (c.type === "modify") modified++;
else if (c.type === "delete") deleted++;
}
return { added, modified, deleted };
}
function ChangeTypeIcon({ type }: { type: "add" | "modify" | "delete" }) {
switch (type) {
case "add":
return <FilePlus className="h-3 w-3 text-emerald-500" />;
case "modify":
return <FilePen className="h-3 w-3 text-amber-500" />;
case "delete":
return <FileX className="h-3 w-3 text-red-500" />;
}
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function PendingApprovalsList({
approvals,
expandedId,
onToggleExpand,
onApprove,
onReject,
}: PendingApprovalsListProps) {
return (
<div className="space-y-4" role="list" aria-label="Approval requests">
{approvals.map((approval) => {
const config = STATUS_CONFIG[approval.status];
const StatusIcon = config.icon;
const isExpanded = expandedId === approval.id;
const counts = changeTypeCounts(approval.changes);
const agentName =
AGENT_DISPLAY_NAMES[approval.requestedBy] ??
approval.requestedBy;
return (
<Card
key={approval.id}
className={cn(
"transition-shadow",
isExpanded && "shadow-md",
approval.status === "pending" && "border-amber-200 dark:border-amber-900/50",
)}
role="listitem"
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
{/* Left: title + metadata */}
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
<div
className={cn(
"flex-shrink-0 rounded-full p-1",
config.bg,
)}
>
<StatusIcon className={cn("h-3.5 w-3.5", config.color)} />
</div>
<CardTitle className="text-sm font-semibold truncate">
{approval.title}
</CardTitle>
<Badge
variant={config.badgeVariant}
className="text-[10px] flex-shrink-0"
>
{config.label}
</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
<span>
Type:{" "}
<Badge variant="secondary" className="text-[10px]">
{approval.type}
</Badge>
</span>
<span>
By:{" "}
<Badge variant="outline" className="text-[10px] font-mono">
{agentName}
</Badge>
</span>
<span>{formatRelativeTime(approval.requestedAt)}</span>
<span>
Session:{" "}
<code className="text-[10px]">
{approval.sessionId.slice(0, 8)}...
</code>
</span>
</div>
{/* Changes summary */}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{counts.added > 0 && (
<span className="flex items-center gap-1 text-emerald-600">
<FilePlus className="h-3 w-3" />
{counts.added} added
</span>
)}
{counts.modified > 0 && (
<span className="flex items-center gap-1 text-amber-600">
<FilePen className="h-3 w-3" />
{counts.modified} modified
</span>
)}
{counts.deleted > 0 && (
<span className="flex items-center gap-1 text-red-600">
<FileX className="h-3 w-3" />
{counts.deleted} deleted
</span>
)}
</div>
</div>
{/* Right: actions */}
<div className="flex items-center gap-2 flex-shrink-0">
{approval.status === "pending" && (
<>
<Button
variant="default"
size="sm"
className="gap-1.5 bg-emerald-600 hover:bg-emerald-700 text-white"
onClick={() => onApprove(approval)}
>
<CheckCircle2 className="h-3.5 w-3.5" />
Approve
</Button>
<Button
variant="destructive"
size="sm"
className="gap-1.5"
onClick={() => onReject(approval)}
>
<XCircle className="h-3.5 w-3.5" />
Reject
</Button>
</>
)}
{/* Resolved info */}
{approval.status !== "pending" && approval.resolvedAt && (
<span className="text-xs text-muted-foreground">
{formatRelativeTime(approval.resolvedAt)}
{approval.resolvedBy && (
<> by {approval.resolvedBy}</>
)}
</span>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onToggleExpand(approval.id)}
aria-label={isExpanded ? "Collapse diff" : "Expand diff"}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</div>
</div>
</CardHeader>
{/* Expanded diff viewer */}
{isExpanded && (
<CardContent className="pt-0">
<Separator className="mb-4" />
{/* Description */}
{approval.description && (
<div className="mb-4">
<p className="text-sm text-muted-foreground">
{approval.description}
</p>
</div>
)}
{/* Diff viewer */}
{approval.changes.length > 0 ? (
<DiffViewer changes={approval.changes} />
) : (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<FileText className="h-8 w-8 mr-3 opacity-50" />
<p className="text-sm">No file changes in this approval.</p>
</div>
)}
{/* Chat panel — replaces static ApprovalComment[] comments */}
{/* See: Agent Chat Channel (chat-channel.md) */}
<ApprovalChatPanel approval={approval} />
</CardContent>
)}
</Card>
);
})}
</div>
);
}
src/pages/approvals/components/diff-viewer.tsx
File diff viewer with syntax-highlighted additions, removals, and context lines.
import { useState } from "react";
import {
ChevronDown,
ChevronRight,
FilePlus,
FilePen,
FileX,
} from "lucide-react";
import type { ApprovalChange } from "@/types/approval";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface DiffViewerProps {
changes: ApprovalChange[];
}
// ---------------------------------------------------------------------------
// Change type config
// ---------------------------------------------------------------------------
const CHANGE_TYPE_CONFIG: Record<
ApprovalChange["type"],
{
icon: typeof FilePlus;
color: string;
bg: string;
label: string;
}
> = {
add: {
icon: FilePlus,
color: "text-emerald-600",
bg: "bg-emerald-100 dark:bg-emerald-900/30",
label: "Added",
},
modify: {
icon: FilePen,
color: "text-amber-600",
bg: "bg-amber-100 dark:bg-amber-900/30",
label: "Modified",
},
delete: {
icon: FileX,
color: "text-red-600",
bg: "bg-red-100 dark:bg-red-900/30",
label: "Deleted",
},
};
// ---------------------------------------------------------------------------
// Diff line parser
// ---------------------------------------------------------------------------
interface DiffLine {
type: "add" | "remove" | "context" | "header";
content: string;
lineNumber?: number;
}
function parseDiffLines(diff: string | undefined): DiffLine[] {
if (!diff) return [];
const lines = diff.split("\n");
const result: DiffLine[] = [];
let addLineNo = 0;
let removeLineNo = 0;
for (const line of lines) {
if (line.startsWith("@@")) {
// Parse hunk header for line numbers
const match = line.match(/@@ -(\d+),?\d* \+(\d+),?\d* @@/);
if (match) {
removeLineNo = parseInt(match[1], 10);
addLineNo = parseInt(match[2], 10);
}
result.push({ type: "header", content: line });
} else if (line.startsWith("+")) {
result.push({
type: "add",
content: line.slice(1),
lineNumber: addLineNo++,
});
} else if (line.startsWith("-")) {
result.push({
type: "remove",
content: line.slice(1),
lineNumber: removeLineNo++,
});
} else {
result.push({
type: "context",
content: line.startsWith(" ") ? line.slice(1) : line,
lineNumber: addLineNo++,
});
removeLineNo++;
}
}
return result;
}
// ---------------------------------------------------------------------------
// Diff line styles
// ---------------------------------------------------------------------------
function diffLineClasses(type: DiffLine["type"]): string {
switch (type) {
case "add":
return "bg-emerald-50 dark:bg-emerald-950/30 text-emerald-800 dark:text-emerald-300";
case "remove":
return "bg-red-50 dark:bg-red-950/30 text-red-800 dark:text-red-300";
case "header":
return "bg-blue-50 dark:bg-blue-950/30 text-blue-700 dark:text-blue-300 font-semibold";
case "context":
default:
return "text-muted-foreground";
}
}
function diffLinePrefix(type: DiffLine["type"]): string {
switch (type) {
case "add":
return "+";
case "remove":
return "-";
case "header":
return "";
case "context":
default:
return " ";
}
}
// ---------------------------------------------------------------------------
// Single file diff section
// ---------------------------------------------------------------------------
function FileDiffSection({ change }: { change: ApprovalChange }) {
const [collapsed, setCollapsed] = useState(false);
const config = CHANGE_TYPE_CONFIG[change.type];
const ChangeIcon = config.icon;
const diffLines = parseDiffLines(change.diff);
return (
<div className="rounded-lg border overflow-hidden">
{/* File header */}
<button
type="button"
onClick={() => setCollapsed(!collapsed)}
className={cn(
"flex items-center gap-2 w-full px-3 py-2 text-left",
"bg-muted/50 hover:bg-muted/80 transition-colors",
"border-b",
)}
>
{collapsed ? (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
)}
<ChangeIcon className={cn("h-3.5 w-3.5", config.color)} />
<code className="text-xs font-mono flex-1 truncate">{change.file}</code>
<Badge
variant="outline"
className={cn("text-[10px] flex-shrink-0", config.color)}
>
{config.label}
</Badge>
</button>
{/* Diff content */}
{!collapsed && (
<div className="overflow-x-auto">
{diffLines.length > 0 ? (
<table className="w-full text-xs font-mono">
<tbody>
{diffLines.map((line, index) => (
<tr
key={index}
className={cn(
"border-b last:border-b-0",
diffLineClasses(line.type),
)}
>
{/* Line number */}
<td className="w-12 px-2 py-0.5 text-right text-muted-foreground/50 select-none border-r">
{line.lineNumber ?? ""}
</td>
{/* Prefix */}
<td className="w-5 px-1 py-0.5 text-center select-none">
{diffLinePrefix(line.type)}
</td>
{/* Content */}
<td className="px-2 py-0.5 whitespace-pre">
{line.content}
</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="px-4 py-6 text-center text-xs text-muted-foreground">
{change.type === "add"
? "New file (no diff available)"
: change.type === "delete"
? "File deleted"
: "Binary file or no diff content"}
</div>
)}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function DiffViewer({ changes }: DiffViewerProps) {
if (changes.length === 0) {
return (
<p className="text-sm text-muted-foreground text-center py-4">
No file changes to display.
</p>
);
}
return (
<div className="space-y-3">
{changes.map((change) => (
<FileDiffSection key={change.file} change={change} />
))}
</div>
);
}
src/pages/approvals/components/approval-dialog.tsx
Confirmation dialog for approving or rejecting an approval request with optional comments.
import { useState } from "react";
import { CheckCircle2, XCircle, Loader2 } from "lucide-react";
import type { Approval } from "@/types/approval";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface ApprovalDialogProps {
approval: Approval;
action: "approve" | "reject";
isLoading: boolean;
onConfirm: (commentOrReason?: string) => void;
onCancel: () => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function ApprovalDialog({
approval,
action,
isLoading,
onConfirm,
onCancel,
}: ApprovalDialogProps) {
const [text, setText] = useState("");
const isApprove = action === "approve";
const isReject = action === "reject";
// Reject requires a reason of at least 10 characters
const isValid = isApprove || text.trim().length >= 10;
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!isValid) return;
onConfirm(text.trim() || undefined);
}
return (
<Dialog open onOpenChange={(open) => !open && onCancel()}>
<DialogContent className="sm:max-w-[480px]">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{isApprove ? (
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
) : (
<XCircle className="h-5 w-5 text-red-600" />
)}
{isApprove ? "Approve Changes" : "Reject Changes"}
</DialogTitle>
<DialogDescription>
{isApprove
? "Confirm that you want to approve the following proposed changes."
: "Provide a reason for rejecting the proposed changes."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Approval summary */}
<div className="rounded-lg border bg-muted/30 p-3 space-y-2">
<p className="text-sm font-medium">{approval.title}</p>
<p className="text-xs text-muted-foreground">
{approval.description}
</p>
<div className="flex items-center gap-2 text-xs">
<Badge variant="secondary" className="text-[10px]">
{approval.type}
</Badge>
<span className="text-muted-foreground">
{approval.changes.length}{" "}
{approval.changes.length === 1 ? "file" : "files"} changed
</span>
</div>
</div>
<Separator />
{/* Comment / Reason textarea */}
<div className="space-y-2">
<Label htmlFor="approval-text" className="text-sm">
{isApprove ? "Comment (optional)" : "Reason (required)"}
</Label>
<textarea
id="approval-text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={
isApprove
? "Add an optional comment..."
: "Explain why this change is being rejected (minimum 10 characters)..."
}
className={cn(
"flex min-h-[100px] w-full rounded-md border border-input",
"bg-background px-3 py-2 text-sm ring-offset-background",
"placeholder:text-muted-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
"resize-y",
)}
disabled={isLoading}
/>
{isReject && text.length > 0 && text.trim().length < 10 && (
<p className="text-xs text-red-500">
Reason must be at least 10 characters ({text.trim().length}/10).
</p>
)}
</div>
</div>
<DialogFooter className="gap-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isLoading}
>
Cancel
</Button>
<Button
type="submit"
disabled={!isValid || isLoading}
className={cn(
isApprove
? "bg-emerald-600 hover:bg-emerald-700 text-white"
: "bg-red-600 hover:bg-red-700 text-white",
)}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{isApprove ? "Approving..." : "Rejecting..."}
</>
) : (
<>
{isApprove ? (
<CheckCircle2 className="mr-2 h-4 w-4" />
) : (
<XCircle className="mr-2 h-4 w-4" />
)}
{isApprove ? "Approve" : "Reject"}
</>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}