learning_ai_notes/web/src/app/(app)/reviews/page.tsx

220 lines
6.9 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import {
Badge,
Panel,
} from "@/components/ui/Primitives";
import {
ProposalDiffCard,
ReviewDecisionBar,
ReviewNoteField,
ReviewQueueList,
ReviewTimeline,
ReviewWorkflowNav,
} from "@/components/reviews/ReviewWorkflow";
import { approveReviewItem, batchReviewItems, listAgentTimeline, listApprovalQueue, rejectReviewItem } from "@/lib/review-client";
import { toast } from "@/lib/toast";
import type { AgentTimelineItem, ApprovalQueueItem } from "@/lib/types";
export default function ReviewsPage() {
const [approvalQueue, setApprovalQueue] = useState<ApprovalQueueItem[]>([]);
const [timeline, setTimeline] = useState<AgentTimelineItem[]>([]);
const [selectedApprovalId, setSelectedApprovalId] = useState<string | null>(null);
const [selectedBatchIds, setSelectedBatchIds] = useState<Set<string>>(new Set());
const [reviewNote, setReviewNote] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void (async () => {
try {
const [nextQueue, nextTimeline] = await Promise.all([
listApprovalQueue(),
listAgentTimeline(),
]);
setApprovalQueue(nextQueue);
setSelectedApprovalId((current) =>
current && nextQueue.some((item) => item.id === current) ? current : nextQueue[0]?.id ?? null,
);
setTimeline(nextTimeline);
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to load review queue");
}
})();
}, []);
const featuredProposal = useMemo(
() => approvalQueue.find((item) => item.id === selectedApprovalId) ?? approvalQueue[0] ?? null,
[approvalQueue, selectedApprovalId],
);
const batchMode = selectedBatchIds.size > 0;
const toggleBatchItem = useCallback((id: string) => {
setSelectedBatchIds((current) => {
const next = new Set(current);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const selectAllForBatch = useCallback(() => {
setSelectedBatchIds(new Set(approvalQueue.map((item) => item.id)));
}, [approvalQueue]);
const clearBatch = useCallback(() => {
setSelectedBatchIds(new Set());
}, []);
const operatorWorkflows = [
{
id: "workflow-approvals",
name: "Approval triage",
owner: "Operator",
queueCount: approvalQueue.length,
sla: "< 4h",
status: approvalQueue.length > 3 ? "at_risk" : "healthy",
},
{
id: "workflow-agent-activity",
name: "Agent activity review",
owner: "Knowledge Ops",
queueCount: timeline.length,
sla: "< 1d",
status: timeline.length > 6 ? "at_risk" : "healthy",
},
] as const;
async function handleDecision(decision: "approved" | "rejected") {
if (!featuredProposal) {
return;
}
setIsSubmitting(true);
const note = reviewNote.trim() || undefined;
try {
const updated =
decision === "approved"
? await approveReviewItem(featuredProposal, note)
: await rejectReviewItem(featuredProposal, note);
setApprovalQueue((current) => {
const nextQueue = current.filter((item) => item.id !== featuredProposal.id);
setSelectedApprovalId((selected) =>
selected === featuredProposal.id ? nextQueue[0]?.id ?? null : selected,
);
return nextQueue;
});
setTimeline((current) => [
{
id: updated.id,
actor: updated.owner,
action: `human ${decision} proposal`,
timestamp: new Date().toISOString(),
status: updated.status,
summary: updated.after ?? updated.title,
},
...current,
]);
setReviewNote("");
toast.success(`Proposal ${decision}`);
} catch (err) {
const msg = err instanceof Error ? err.message : "Unable to update review state";
setError(msg);
toast.error(msg);
} finally {
setIsSubmitting(false);
}
}
async function handleBatchDecision(decision: "approved" | "rejected") {
const batchItems = approvalQueue.filter((item) => selectedBatchIds.has(item.id));
if (batchItems.length === 0) return;
setIsSubmitting(true);
const note = reviewNote.trim() || undefined;
try {
await batchReviewItems(batchItems, decision, note);
setApprovalQueue((current) => {
const nextQueue = current.filter((item) => !selectedBatchIds.has(item.id));
setSelectedApprovalId(nextQueue[0]?.id ?? null);
return nextQueue;
});
setTimeline((current) => [
{
id: `batch-${Date.now()}`,
actor: "reviewer",
action: `batch ${decision} ${batchItems.length} proposals`,
timestamp: new Date().toISOString(),
status: decision,
summary: note ?? `${batchItems.length} items ${decision}`,
},
...current,
]);
setSelectedBatchIds(new Set());
setReviewNote("");
toast.success(`Batch ${decision}: ${batchItems.length} items`);
} catch (err) {
const msg = err instanceof Error ? err.message : "Batch review failed";
setError(msg);
toast.error(msg);
} finally {
setIsSubmitting(false);
}
}
return (
<AppShell
title="Agent review"
description="Approval queue, proposal comparison, and audit-oriented review surfaces for agent-mediated edits."
actions={<Badge>Operator workflow shell</Badge>}
>
<section style={{ display: "grid", gridTemplateColumns: "minmax(260px, 320px) minmax(0, 1fr)", gap: "var(--nl-space-4)" }}>
<ReviewWorkflowNav workflows={operatorWorkflows} />
<Panel>
<ReviewDecisionBar
batchMode={batchMode}
selectedCount={selectedBatchIds.size}
isSubmitting={isSubmitting}
onSelectAll={selectAllForBatch}
onClear={clearBatch}
onBatchDecision={(decision) => void handleBatchDecision(decision)}
/>
{error ? <div style={{ color: "var(--nl-text-secondary)" }}>{error}</div> : null}
<ReviewQueueList
items={approvalQueue}
batchMode={batchMode}
selectedBatchIds={selectedBatchIds}
selectedApprovalId={featuredProposal?.id ?? null}
onSelectItem={(id) => batchMode ? toggleBatchItem(id) : setSelectedApprovalId(id)}
/>
</Panel>
</section>
<ReviewNoteField value={reviewNote} onChange={setReviewNote} />
{featuredProposal && !batchMode ? (
<ProposalDiffCard
proposal={featuredProposal}
onApprove={() => void handleDecision("approved")}
onReject={() => void handleDecision("rejected")}
isSubmitting={isSubmitting}
/>
) : null}
<ReviewTimeline items={timeline} />
</AppShell>
);
}