241 lines
6.3 KiB
TypeScript
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} />;
|
|
}
|