220 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}
|