refactor(ui): split review workflow components
This commit is contained in:
parent
936d2899fe
commit
de75d93e59
@ -1,21 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
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 {
|
||||
ProposalDiffCard,
|
||||
ReviewDecisionBar,
|
||||
ReviewNoteField,
|
||||
ReviewQueueList,
|
||||
ReviewTimeline,
|
||||
ReviewWorkflowNav,
|
||||
} from "@/components/reviews/ReviewWorkflow";
|
||||
import { approveReviewItem, batchReviewItems, listAgentTimeline, listApprovalQueue, rejectReviewItem } from "@/lib/review-client";
|
||||
import { toast } from "@/lib/toast";
|
||||
import type { AgentTimelineItem, ApprovalQueueItem } from "@/lib/types";
|
||||
@ -93,14 +91,6 @@ export default function ReviewsPage() {
|
||||
},
|
||||
] as const;
|
||||
|
||||
function getWorkflowHref(workflow: (typeof operatorWorkflows)[number]) {
|
||||
if (workflow.id === "workflow-agent-activity") {
|
||||
return "/reviews";
|
||||
}
|
||||
|
||||
return "/reviews";
|
||||
}
|
||||
|
||||
async function handleDecision(decision: "approved" | "rejected") {
|
||||
if (!featuredProposal) {
|
||||
return;
|
||||
@ -189,131 +179,41 @@ export default function ReviewsPage() {
|
||||
actions={<Badge>Operator workflow shell</Badge>}
|
||||
>
|
||||
<section style={{ display: "grid", gridTemplateColumns: "minmax(260px, 320px) minmax(0, 1fr)", gap: "var(--nl-space-4)" }}>
|
||||
<Panel as="aside">
|
||||
<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>
|
||||
<ReviewWorkflowNav workflows={operatorWorkflows} />
|
||||
|
||||
<Panel>
|
||||
<PanelHeader>
|
||||
<PanelTitle>Approval queue</PanelTitle>
|
||||
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap", alignItems: "center" }}>
|
||||
{batchMode ? (
|
||||
<>
|
||||
<Badge>{selectedBatchIds.size} selected</Badge>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={clearBatch}>Clear</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={selectAllForBatch}>Select all</Button>
|
||||
)}
|
||||
<StatusBadge status="pending">status:pending</StatusBadge>
|
||||
</div>
|
||||
</PanelHeader>
|
||||
<ReviewDecisionBar
|
||||
batchMode={batchMode}
|
||||
selectedCount={selectedBatchIds.size}
|
||||
isSubmitting={isSubmitting}
|
||||
onSelectAll={selectAllForBatch}
|
||||
onClear={clearBatch}
|
||||
onBatchDecision={(decision) => void handleBatchDecision(decision)}
|
||||
/>
|
||||
{error ? <div style={{ color: "var(--nl-text-secondary)" }}>{error}</div> : null}
|
||||
|
||||
{batchMode ? (
|
||||
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => void handleBatchDecision("approved")}
|
||||
>
|
||||
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>
|
||||
<ReviewQueueList
|
||||
items={approvalQueue}
|
||||
batchMode={batchMode}
|
||||
selectedBatchIds={selectedBatchIds}
|
||||
selectedApprovalId={featuredProposal?.id ?? null}
|
||||
onSelectItem={(id) => batchMode ? toggleBatchItem(id) : setSelectedApprovalId(id)}
|
||||
/>
|
||||
</Panel>
|
||||
</section>
|
||||
|
||||
<Panel>
|
||||
<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>
|
||||
<ReviewNoteField value={reviewNote} onChange={setReviewNote} />
|
||||
|
||||
{featuredProposal && !batchMode ? (
|
||||
<ProposalReviewCard
|
||||
title={featuredProposal.title}
|
||||
before={featuredProposal.before ?? "No prior summary captured."}
|
||||
after={featuredProposal.after ?? "No proposed change summary captured yet."}
|
||||
<ProposalDiffCard
|
||||
proposal={featuredProposal}
|
||||
onApprove={() => void handleDecision("approved")}
|
||||
onReject={() => void handleDecision("rejected")}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AgentTimeline items={timeline} />
|
||||
<ReviewTimeline items={timeline} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
216
web/src/components/reviews/ReviewWorkflow.tsx
Normal file
216
web/src/components/reviews/ReviewWorkflow.tsx
Normal 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} />;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user