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:
parent
bdbf387f88
commit
ca3cdbad4e
@ -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."}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user