refactor(ui): split review workflow components

This commit is contained in:
Saravana Achu Mac 2026-05-06 13:13:46 -07:00
parent 936d2899fe
commit de75d93e59
2 changed files with 244 additions and 128 deletions

View File

@ -1,21 +1,19 @@
"use client"; "use client";
import Link from "next/link";
import { useCallback, 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 { ProposalReviewCard } from "@/components/ProposalReviewCard";
import { import {
Badge, Badge,
Button,
ListItemButton,
Panel, Panel,
PanelBody,
PanelHeader,
PanelTitle,
StatusBadge,
Textarea,
} from "@/components/ui/Primitives"; } 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 { approveReviewItem, batchReviewItems, listAgentTimeline, listApprovalQueue, rejectReviewItem } from "@/lib/review-client";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import type { AgentTimelineItem, ApprovalQueueItem } from "@/lib/types"; import type { AgentTimelineItem, ApprovalQueueItem } from "@/lib/types";
@ -93,14 +91,6 @@ export default function ReviewsPage() {
}, },
] as const; ] as const;
function getWorkflowHref(workflow: (typeof operatorWorkflows)[number]) {
if (workflow.id === "workflow-agent-activity") {
return "/reviews";
}
return "/reviews";
}
async function handleDecision(decision: "approved" | "rejected") { async function handleDecision(decision: "approved" | "rejected") {
if (!featuredProposal) { if (!featuredProposal) {
return; return;
@ -189,131 +179,41 @@ export default function ReviewsPage() {
actions={<Badge>Operator workflow shell</Badge>} actions={<Badge>Operator workflow shell</Badge>}
> >
<section style={{ display: "grid", gridTemplateColumns: "minmax(260px, 320px) minmax(0, 1fr)", gap: "var(--nl-space-4)" }}> <section style={{ display: "grid", gridTemplateColumns: "minmax(260px, 320px) minmax(0, 1fr)", gap: "var(--nl-space-4)" }}>
<Panel as="aside"> <ReviewWorkflowNav workflows={operatorWorkflows} />
<PanelHeader>
<PanelTitle>Operator workflows</PanelTitle>
</PanelHeader>
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
{operatorWorkflows.map((workflow) => (
<Link
key={workflow.id}
href={getWorkflowHref(workflow)}
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>
<Panel> <Panel>
<PanelHeader> <ReviewDecisionBar
<PanelTitle>Approval queue</PanelTitle> batchMode={batchMode}
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap", alignItems: "center" }}> selectedCount={selectedBatchIds.size}
{batchMode ? ( isSubmitting={isSubmitting}
<> onSelectAll={selectAllForBatch}
<Badge>{selectedBatchIds.size} selected</Badge> onClear={clearBatch}
<Button type="button" variant="ghost" size="sm" onClick={clearBatch}>Clear</Button> onBatchDecision={(decision) => void handleBatchDecision(decision)}
</> />
) : (
<Button type="button" variant="ghost" size="sm" onClick={selectAllForBatch}>Select all</Button>
)}
<StatusBadge status="pending">status:pending</StatusBadge>
</div>
</PanelHeader>
{error ? <div style={{ color: "var(--nl-text-secondary)" }}>{error}</div> : null} {error ? <div style={{ color: "var(--nl-text-secondary)" }}>{error}</div> : null}
{batchMode ? ( <ReviewQueueList
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}> items={approvalQueue}
<Button batchMode={batchMode}
type="button" selectedBatchIds={selectedBatchIds}
disabled={isSubmitting} selectedApprovalId={featuredProposal?.id ?? null}
onClick={() => void handleBatchDecision("approved")} onSelectItem={(id) => batchMode ? toggleBatchItem(id) : setSelectedApprovalId(id)}
> />
Approve {selectedBatchIds.size}
</Button>
<Button
type="button"
disabled={isSubmitting}
onClick={() => void handleBatchDecision("rejected")}
variant="destructive"
>
Reject {selectedBatchIds.size}
</Button>
</div>
) : null}
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
{approvalQueue.map((item) => (
<ListItemButton
key={item.id}
selected={
batchMode
? selectedBatchIds.has(item.id)
: featuredProposal?.id === item.id
}
onClick={() => batchMode ? toggleBatchItem(item.id) : setSelectedApprovalId(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>
</Panel> </Panel>
</section> </section>
<Panel> <ReviewNoteField value={reviewNote} onChange={setReviewNote} />
<PanelBody>
<Textarea
id="review-note"
label="Review note"
value={reviewNote}
onChange={(e) => setReviewNote(e.target.value)}
placeholder="Optional: add a note explaining your decision…"
rows={2}
/>
</PanelBody>
</Panel>
{featuredProposal && !batchMode ? ( {featuredProposal && !batchMode ? (
<ProposalReviewCard <ProposalDiffCard
title={featuredProposal.title} proposal={featuredProposal}
before={featuredProposal.before ?? "No prior summary captured."}
after={featuredProposal.after ?? "No proposed change summary captured yet."}
onApprove={() => void handleDecision("approved")} onApprove={() => void handleDecision("approved")}
onReject={() => void handleDecision("rejected")} onReject={() => void handleDecision("rejected")}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
/> />
) : null} ) : null}
<AgentTimeline items={timeline} /> <ReviewTimeline items={timeline} />
</AppShell> </AppShell>
); );
} }

View File

@ -0,0 +1,216 @@
import Link from "next/link";
import { AgentTimeline } from "@/components/AgentTimeline";
import { ProposalReviewCard } from "@/components/ProposalReviewCard";
import {
Badge,
Button,
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;
}) {
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[] }) {
return <AgentTimeline items={items} />;
}