refactor(ui): split review workflow components
This commit is contained in:
parent
936d2899fe
commit
de75d93e59
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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