feat(web): wire full review UX — batch select, review notes, batch approve/reject

- Reviews page: batch selection mode with Select All / Clear
- Reviews page: batch Approve N / Reject N buttons
- Reviews page: review note textarea shared by single + batch flows
- review-client.ts: added batchReviewItems() calling POST /batch-review
- review-client.ts: approve/reject now pass reviewNote to backend
- Clears review note + batch selection after successful action

Verification: web typecheck passes.
This commit is contained in:
saravanakumardb1 2026-03-10 19:35:40 -07:00
parent bdbf387f88
commit ca3cdbad4e
2 changed files with 171 additions and 16 deletions

View File

@ -1,17 +1,19 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { AgentTimeline } from "@/components/AgentTimeline"; import { AgentTimeline } from "@/components/AgentTimeline";
import { ProposalReviewCard } from "@/components/ProposalReviewCard"; import { ProposalReviewCard } from "@/components/ProposalReviewCard";
import { approveReviewItem, listAgentTimeline, listApprovalQueue, rejectReviewItem } from "@/lib/review-client"; import { approveReviewItem, batchReviewItems, listAgentTimeline, listApprovalQueue, rejectReviewItem } from "@/lib/review-client";
import type { AgentTimelineItem, ApprovalQueueItem } from "@/lib/types"; import type { AgentTimelineItem, ApprovalQueueItem } from "@/lib/types";
export default function ReviewsPage() { export default function ReviewsPage() {
const [approvalQueue, setApprovalQueue] = useState<ApprovalQueueItem[]>([]); const [approvalQueue, setApprovalQueue] = useState<ApprovalQueueItem[]>([]);
const [timeline, setTimeline] = useState<AgentTimelineItem[]>([]); const [timeline, setTimeline] = useState<AgentTimelineItem[]>([]);
const [selectedApprovalId, setSelectedApprovalId] = useState<string | null>(null); 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 [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -37,6 +39,29 @@ export default function ReviewsPage() {
() => approvalQueue.find((item) => item.id === selectedApprovalId) ?? approvalQueue[0] ?? null, () => approvalQueue.find((item) => item.id === selectedApprovalId) ?? approvalQueue[0] ?? null,
[approvalQueue, selectedApprovalId], [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 = [ const operatorWorkflows = [
{ {
id: "workflow-approvals", id: "workflow-approvals",
@ -70,12 +95,13 @@ export default function ReviewsPage() {
} }
setIsSubmitting(true); setIsSubmitting(true);
const note = reviewNote.trim() || undefined;
try { try {
const updated = const updated =
decision === "approved" decision === "approved"
? await approveReviewItem(featuredProposal) ? await approveReviewItem(featuredProposal, note)
: await rejectReviewItem(featuredProposal); : await rejectReviewItem(featuredProposal, note);
setApprovalQueue((current) => { setApprovalQueue((current) => {
const nextQueue = current.filter((item) => item.id !== featuredProposal.id); const nextQueue = current.filter((item) => item.id !== featuredProposal.id);
@ -88,13 +114,14 @@ export default function ReviewsPage() {
{ {
id: updated.id, id: updated.id,
actor: updated.owner, actor: updated.owner,
action: `human ${decision === "approved" ? "approved" : "rejected"} proposal`, action: `human ${decision} proposal`,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
status: updated.status, status: updated.status,
summary: updated.after ?? updated.title, summary: updated.after ?? updated.title,
}, },
...current, ...current,
]); ]);
setReviewNote("");
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Unable to update review state"); setError(err instanceof Error ? err.message : "Unable to update review state");
} finally { } finally {
@ -102,6 +129,41 @@ export default function ReviewsPage() {
} }
} }
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("");
} catch (err) {
setError(err instanceof Error ? err.message : "Batch review failed");
} finally {
setIsSubmitting(false);
}
}
return ( return (
<AppShell <AppShell
title="Agent review" title="Agent review"
@ -134,20 +196,62 @@ export default function ReviewsPage() {
<section className="surface-card" style={{ padding: "var(--ml-space-5)", display: "grid", gap: "var(--ml-space-4)" }}> <section className="surface-card" style={{ padding: "var(--ml-space-5)", display: "grid", gap: "var(--ml-space-4)" }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--ml-space-3)", flexWrap: "wrap" }}> <div style={{ display: "flex", justifyContent: "space-between", gap: "var(--ml-space-3)", flexWrap: "wrap" }}>
<div style={{ fontWeight: 700 }}>Approval queue</div> <div style={{ fontWeight: 700 }}>Approval queue</div>
<div style={{ display: "flex", gap: "var(--ml-space-2)", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "var(--ml-space-2)", flexWrap: "wrap", alignItems: "center" }}>
<span className="badge">severity:medium+</span> {batchMode ? (
<>
<span className="badge">{selectedBatchIds.size} selected</span>
<button type="button" className="badge" onClick={clearBatch} style={{ cursor: "pointer" }}>Clear</button>
</>
) : (
<button type="button" className="badge" onClick={selectAllForBatch} style={{ cursor: "pointer" }}>Select all</button>
)}
<span className="badge">status:pending</span> <span className="badge">status:pending</span>
<span className="badge">owner:any</span>
</div> </div>
</div> </div>
{error ? <div style={{ color: "var(--ml-text-secondary)" }}>{error}</div> : null} {error ? <div style={{ color: "var(--ml-text-secondary)" }}>{error}</div> : null}
{batchMode ? (
<div style={{ display: "flex", gap: "var(--ml-space-2)", flexWrap: "wrap" }}>
<button
type="button"
disabled={isSubmitting}
onClick={() => void handleBatchDecision("approved")}
style={{
border: "none",
borderRadius: "var(--ml-radius-md)",
padding: "8px 16px",
background: "var(--ml-accent-primary)",
color: "var(--ml-text-primary)",
fontWeight: 600,
cursor: "pointer",
}}
>
Approve {selectedBatchIds.size}
</button>
<button
type="button"
disabled={isSubmitting}
onClick={() => void handleBatchDecision("rejected")}
className="surface-muted"
style={{
border: "1px solid var(--ml-border-subtle)",
borderRadius: "var(--ml-radius-md)",
padding: "8px 16px",
cursor: "pointer",
}}
>
Reject {selectedBatchIds.size}
</button>
</div>
) : null}
<div style={{ display: "grid", gap: "var(--ml-space-3)" }}> <div style={{ display: "grid", gap: "var(--ml-space-3)" }}>
{approvalQueue.map((item) => ( {approvalQueue.map((item) => (
<button <button
key={item.id} key={item.id}
type="button" type="button"
className="surface-muted" className="surface-muted"
onClick={() => setSelectedApprovalId(item.id)} onClick={() => batchMode ? toggleBatchItem(item.id) : setSelectedApprovalId(item.id)}
style={{ style={{
padding: "var(--ml-space-4)", padding: "var(--ml-space-4)",
display: "flex", display: "flex",
@ -157,8 +261,12 @@ export default function ReviewsPage() {
flexWrap: "wrap", flexWrap: "wrap",
textAlign: "left", textAlign: "left",
width: "100%", width: "100%",
borderColor: featuredProposal?.id === item.id ? "var(--ml-accent-primary)" : undefined, borderColor: batchMode
background: featuredProposal?.id === item.id ? "rgba(90, 140, 255, 0.12)" : undefined, ? selectedBatchIds.has(item.id) ? "var(--ml-accent-primary)" : undefined
: featuredProposal?.id === item.id ? "var(--ml-accent-primary)" : undefined,
background: batchMode
? selectedBatchIds.has(item.id) ? "rgba(90, 140, 255, 0.12)" : undefined
: featuredProposal?.id === item.id ? "rgba(90, 140, 255, 0.12)" : undefined,
}} }}
> >
<div style={{ display: "grid", gap: 4 }}> <div style={{ display: "grid", gap: 4 }}>
@ -166,6 +274,9 @@ export default function ReviewsPage() {
<span style={{ color: "var(--ml-text-secondary)" }}>{item.owner}</span> <span style={{ color: "var(--ml-text-secondary)" }}>{item.owner}</span>
</div> </div>
<div style={{ display: "flex", gap: "var(--ml-space-2)", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "var(--ml-space-2)", flexWrap: "wrap" }}>
{batchMode ? (
<span className="badge">{selectedBatchIds.has(item.id) ? "selected" : "unselected"}</span>
) : null}
<span className="badge">{item.severity}</span> <span className="badge">{item.severity}</span>
<span className="badge">{item.status}</span> <span className="badge">{item.status}</span>
</div> </div>
@ -175,7 +286,29 @@ export default function ReviewsPage() {
</section> </section>
</section> </section>
{featuredProposal ? ( <section className="surface-card" style={{ padding: "var(--ml-space-5)", display: "grid", gap: "var(--ml-space-3)" }}>
<label htmlFor="review-note" style={{ fontWeight: 600 }}>Review note</label>
<textarea
id="review-note"
value={reviewNote}
onChange={(e) => setReviewNote(e.target.value)}
placeholder="Optional: add a note explaining your decision…"
rows={2}
style={{
width: "100%",
padding: "var(--ml-space-3)",
background: "var(--ml-bg-elevated)",
color: "var(--ml-text-primary)",
border: "1px solid var(--ml-border-subtle)",
borderRadius: "var(--ml-radius-md)",
resize: "vertical",
fontFamily: "inherit",
fontSize: "0.875rem",
}}
/>
</section>
{featuredProposal && !batchMode ? (
<ProposalReviewCard <ProposalReviewCard
title={featuredProposal.title} title={featuredProposal.title}
before={featuredProposal.before ?? "No prior summary captured."} before={featuredProposal.before ?? "No prior summary captured."}

View File

@ -81,6 +81,7 @@ async function updateAgentActionState(
id: string, id: string,
workspaceId: string, workspaceId: string,
state: "approved" | "rejected", state: "approved" | "rejected",
reviewNote?: string,
): Promise<NoteAgentActionDoc> { ): Promise<NoteAgentActionDoc> {
const api = createNotesApiClient(); const api = createNotesApiClient();
@ -91,6 +92,7 @@ async function updateAgentActionState(
body: JSON.stringify({ body: JSON.stringify({
state, state,
reviewedAt: new Date().toISOString(), reviewedAt: new Date().toISOString(),
...(reviewNote ? { reviewNote } : {}),
}), }),
}, },
); );
@ -130,12 +132,32 @@ export async function listAgentTimeline(noteId?: string): Promise<AgentTimelineI
.map(toTimelineItem); .map(toTimelineItem);
} }
export async function approveReviewItem(item: ApprovalQueueItem): Promise<ApprovalQueueItem> { export async function approveReviewItem(item: ApprovalQueueItem, reviewNote?: string): Promise<ApprovalQueueItem> {
const updated = await updateAgentActionState(item.id, item.workspaceId, "approved"); const updated = await updateAgentActionState(item.id, item.workspaceId, "approved", reviewNote);
return toApprovalQueueItem(updated); return toApprovalQueueItem(updated);
} }
export async function rejectReviewItem(item: ApprovalQueueItem): Promise<ApprovalQueueItem> { export async function rejectReviewItem(item: ApprovalQueueItem, reviewNote?: string): Promise<ApprovalQueueItem> {
const updated = await updateAgentActionState(item.id, item.workspaceId, "rejected"); const updated = await updateAgentActionState(item.id, item.workspaceId, "rejected", reviewNote);
return toApprovalQueueItem(updated); return toApprovalQueueItem(updated);
} }
export async function batchReviewItems(
items: ApprovalQueueItem[],
state: "approved" | "rejected",
reviewNote?: string,
): Promise<{ updated: number; total: number }> {
const api = createNotesApiClient();
const result = await api.fetch<{ updated: number; total: number }>(
"/note-agent-actions/batch-review",
{
method: "POST",
body: JSON.stringify({
ids: items.map((item) => ({ id: item.id, workspaceId: item.workspaceId })),
state,
...(reviewNote ? { reviewNote } : {}),
}),
},
);
return result;
}