learning_ai_notes/web/src/components/reviews/ReviewWorkflow.tsx

241 lines
6.3 KiB
TypeScript

import Link from "next/link";
import { AgentTimeline } from "@/components/AgentTimeline";
import { ProposalReviewCard } from "@/components/ProposalReviewCard";
import {
Badge,
Button,
EmptyState,
ListItemButton,
Panel,
PanelBody,
PanelHeader,
PanelTitle,
StatusBadge,
Textarea,
} from "@/components/ui/Primitives";
import type { AgentTimelineItem, ApprovalQueueItem } from "@/lib/types";
type ReviewWorkflow = {
id: string;
name: string;
owner: string;
queueCount: number;
sla: string;
status: "healthy" | "at_risk";
};
type ReviewDecision = "approved" | "rejected";
export function ReviewWorkflowNav({ workflows }: { workflows: readonly ReviewWorkflow[] }) {
return (
<Panel as="aside">
<PanelHeader>
<PanelTitle>Operator workflows</PanelTitle>
</PanelHeader>
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
{workflows.map((workflow) => (
<Link
key={workflow.id}
href="/reviews"
style={{ display: "grid", gap: "var(--nl-space-2)" }}
>
<strong>{workflow.name}</strong>
<span style={{ color: "var(--nl-text-secondary)" }}>Owner: {workflow.owner}</span>
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
<StatusBadge status={workflow.status === "healthy" ? "approved" : "pending"}>
{workflow.status}
</StatusBadge>
<Badge>Queue: {workflow.queueCount}</Badge>
<Badge>SLA {workflow.sla}</Badge>
</div>
</Link>
))}
</div>
</Panel>
);
}
export function ReviewDecisionBar({
batchMode,
selectedCount,
isSubmitting,
onSelectAll,
onClear,
onBatchDecision,
}: {
batchMode: boolean;
selectedCount: number;
isSubmitting: boolean;
onSelectAll: () => void;
onClear: () => void;
onBatchDecision: (decision: ReviewDecision) => void;
}) {
return (
<>
<PanelHeader>
<PanelTitle>Approval queue</PanelTitle>
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap", alignItems: "center" }}>
{batchMode ? (
<>
<Badge>{selectedCount} selected</Badge>
<Button type="button" variant="ghost" size="sm" onClick={onClear}>
Clear
</Button>
</>
) : (
<Button type="button" variant="ghost" size="sm" onClick={onSelectAll}>
Select all
</Button>
)}
<StatusBadge status="pending">status:pending</StatusBadge>
</div>
</PanelHeader>
{batchMode ? (
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
<Button
type="button"
disabled={isSubmitting}
onClick={() => onBatchDecision("approved")}
>
Approve {selectedCount}
</Button>
<Button
type="button"
disabled={isSubmitting}
onClick={() => onBatchDecision("rejected")}
variant="destructive"
>
Reject {selectedCount}
</Button>
</div>
) : null}
</>
);
}
export function ReviewQueueList({
items,
batchMode,
selectedBatchIds,
selectedApprovalId,
onSelectItem,
}: {
items: ApprovalQueueItem[];
batchMode: boolean;
selectedBatchIds: Set<string>;
selectedApprovalId: string | null;
onSelectItem: (id: string) => void;
}) {
if (items.length === 0) {
return (
<EmptyState
title="No proposals need review"
description="Agent-mediated changes that need human approval will appear here."
/>
);
}
return (
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
{items.map((item) => (
<ListItemButton
key={item.id}
selected={batchMode ? selectedBatchIds.has(item.id) : selectedApprovalId === item.id}
onClick={() => onSelectItem(item.id)}
style={{
display: "flex",
justifyContent: "space-between",
gap: "var(--nl-space-3)",
alignItems: "center",
flexWrap: "wrap",
textAlign: "left",
}}
>
<div style={{ display: "grid", gap: 4 }}>
<strong>{item.title}</strong>
<span style={{ color: "var(--nl-text-secondary)" }}>{item.owner}</span>
</div>
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
{batchMode ? (
<Badge>{selectedBatchIds.has(item.id) ? "selected" : "unselected"}</Badge>
) : null}
<Badge>{item.severity}</Badge>
<StatusBadge status={item.status === "proposed" ? "proposed" : "pending"}>
{item.status}
</StatusBadge>
</div>
</ListItemButton>
))}
</div>
);
}
export function ReviewNoteField({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
return (
<Panel>
<PanelBody>
<Textarea
id="review-note"
label="Review note"
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder="Optional: add a note explaining your decision..."
rows={2}
/>
</PanelBody>
</Panel>
);
}
export function ProposalDiffCard({
proposal,
isSubmitting,
onApprove,
onReject,
}: {
proposal: ApprovalQueueItem | null;
isSubmitting: boolean;
onApprove: () => void;
onReject: () => void;
}) {
if (!proposal) {
return null;
}
return (
<ProposalReviewCard
title={proposal.title}
before={proposal.before ?? "No prior summary captured."}
after={proposal.after ?? "No proposed change summary captured yet."}
onApprove={onApprove}
onReject={onReject}
isSubmitting={isSubmitting}
/>
);
}
export function ReviewTimeline({ items }: { items: AgentTimelineItem[] }) {
if (items.length === 0) {
return (
<Panel>
<PanelHeader>
<PanelTitle>Agent activity timeline</PanelTitle>
</PanelHeader>
<EmptyState
title="No review activity yet"
description="Approvals, rejections, and agent changes will be listed as they happen."
/>
</Panel>
);
}
return <AgentTimeline items={items} />;
}