feat(ui): migrate review workflow primitives

This commit is contained in:
Saravana Achu Mac 2026-05-06 11:43:34 -07:00
parent 1784f72d70
commit c79aa2b6fd
4 changed files with 98 additions and 118 deletions

View File

@ -5,6 +5,17 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { AgentTimeline } from "@/components/AgentTimeline"; import { AgentTimeline } from "@/components/AgentTimeline";
import { ProposalReviewCard } from "@/components/ProposalReviewCard"; import { ProposalReviewCard } from "@/components/ProposalReviewCard";
import {
Badge,
Button,
ListItemButton,
Panel,
PanelBody,
PanelHeader,
PanelTitle,
StatusBadge,
Textarea,
} from "@/components/ui/Primitives";
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";
@ -175,105 +186,88 @@ export default function ReviewsPage() {
<AppShell <AppShell
title="Agent review" title="Agent review"
description="Approval queue, proposal comparison, and audit-oriented review surfaces for agent-mediated edits." description="Approval queue, proposal comparison, and audit-oriented review surfaces for agent-mediated edits."
actions={<div className="badge">Operator workflow shell</div>} 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)" }}>
<aside className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-4)" }}> <Panel as="aside">
<div style={{ fontWeight: 700 }}>Operator workflows</div> <PanelHeader>
<PanelTitle>Operator workflows</PanelTitle>
</PanelHeader>
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}> <div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
{operatorWorkflows.map((workflow) => ( {operatorWorkflows.map((workflow) => (
<Link <Link
key={workflow.id} key={workflow.id}
href={getWorkflowHref(workflow)} href={getWorkflowHref(workflow)}
className="surface-muted" style={{ display: "grid", gap: "var(--nl-space-2)" }}
style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}
> >
<strong>{workflow.name}</strong> <strong>{workflow.name}</strong>
<span style={{ color: "var(--nl-text-secondary)" }}>Owner: {workflow.owner}</span> <span style={{ color: "var(--nl-text-secondary)" }}>Owner: {workflow.owner}</span>
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
<span className="badge">{workflow.status}</span> <StatusBadge status={workflow.status === "healthy" ? "approved" : "pending"}>
<span className="badge">Queue: {workflow.queueCount}</span> {workflow.status}
<span className="badge">SLA {workflow.sla}</span> </StatusBadge>
<Badge>Queue: {workflow.queueCount}</Badge>
<Badge>SLA {workflow.sla}</Badge>
</div> </div>
</Link> </Link>
))} ))}
</div> </div>
</aside> </Panel>
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-4)" }}> <Panel>
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", flexWrap: "wrap" }}> <PanelHeader>
<div style={{ fontWeight: 700 }}>Approval queue</div> <PanelTitle>Approval queue</PanelTitle>
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap", alignItems: "center" }}> <div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap", alignItems: "center" }}>
{batchMode ? ( {batchMode ? (
<> <>
<span className="badge">{selectedBatchIds.size} selected</span> <Badge>{selectedBatchIds.size} selected</Badge>
<button type="button" className="badge" onClick={clearBatch} style={{ cursor: "pointer" }}>Clear</button> <Button type="button" variant="ghost" size="sm" onClick={clearBatch}>Clear</Button>
</> </>
) : ( ) : (
<button type="button" className="badge" onClick={selectAllForBatch} style={{ cursor: "pointer" }}>Select all</button> <Button type="button" variant="ghost" size="sm" onClick={selectAllForBatch}>Select all</Button>
)} )}
<span className="badge">status:pending</span> <StatusBadge status="pending">status:pending</StatusBadge>
</div> </div>
</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 ? ( {batchMode ? (
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
<button <Button
type="button" type="button"
disabled={isSubmitting} disabled={isSubmitting}
onClick={() => void handleBatchDecision("approved")} onClick={() => void handleBatchDecision("approved")}
style={{
border: "none",
borderRadius: "var(--nl-radius-md)",
padding: "8px 16px",
background: "var(--nl-accent-primary)",
color: "var(--nl-text-primary)",
fontWeight: 600,
cursor: "pointer",
}}
> >
Approve {selectedBatchIds.size} Approve {selectedBatchIds.size}
</button> </Button>
<button <Button
type="button" type="button"
disabled={isSubmitting} disabled={isSubmitting}
onClick={() => void handleBatchDecision("rejected")} onClick={() => void handleBatchDecision("rejected")}
className="surface-muted" variant="destructive"
style={{
border: "1px solid var(--nl-border-subtle)",
borderRadius: "var(--nl-radius-md)",
padding: "8px 16px",
cursor: "pointer",
}}
> >
Reject {selectedBatchIds.size} Reject {selectedBatchIds.size}
</button> </Button>
</div> </div>
) : null} ) : null}
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}> <div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
{approvalQueue.map((item) => ( {approvalQueue.map((item) => (
<button <ListItemButton
key={item.id} key={item.id}
type="button" selected={
className="surface-muted" batchMode
? selectedBatchIds.has(item.id)
: featuredProposal?.id === item.id
}
onClick={() => batchMode ? toggleBatchItem(item.id) : setSelectedApprovalId(item.id)} onClick={() => batchMode ? toggleBatchItem(item.id) : setSelectedApprovalId(item.id)}
style={{ style={{
padding: "var(--nl-space-4)",
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
gap: "var(--nl-space-3)", gap: "var(--nl-space-3)",
alignItems: "center", alignItems: "center",
flexWrap: "wrap", flexWrap: "wrap",
textAlign: "left", textAlign: "left",
width: "100%",
borderColor: batchMode
? selectedBatchIds.has(item.id) ? "var(--nl-accent-primary)" : undefined
: featuredProposal?.id === item.id ? "var(--nl-accent-primary)" : undefined,
background: batchMode
? selectedBatchIds.has(item.id) ? "var(--nl-accent-muted)" : undefined
: featuredProposal?.id === item.id ? "var(--nl-accent-muted)" : undefined,
}} }}
> >
<div style={{ display: "grid", gap: 4 }}> <div style={{ display: "grid", gap: 4 }}>
@ -282,38 +276,31 @@ export default function ReviewsPage() {
</div> </div>
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
{batchMode ? ( {batchMode ? (
<span className="badge">{selectedBatchIds.has(item.id) ? "selected" : "unselected"}</span> <Badge>{selectedBatchIds.has(item.id) ? "selected" : "unselected"}</Badge>
) : null} ) : null}
<span className="badge">{item.severity}</span> <Badge>{item.severity}</Badge>
<span className="badge">{item.status}</span> <StatusBadge status={item.status === "proposed" ? "proposed" : "pending"}>
{item.status}
</StatusBadge>
</div> </div>
</button> </ListItemButton>
))} ))}
</div> </div>
</section> </Panel>
</section> </section>
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}> <Panel>
<label htmlFor="review-note" style={{ fontWeight: 600 }}>Review note</label> <PanelBody>
<textarea <Textarea
id="review-note" id="review-note"
label="Review note"
value={reviewNote} value={reviewNote}
onChange={(e) => setReviewNote(e.target.value)} onChange={(e) => setReviewNote(e.target.value)}
placeholder="Optional: add a note explaining your decision…" placeholder="Optional: add a note explaining your decision…"
rows={2} rows={2}
style={{
width: "100%",
padding: "var(--nl-space-3)",
background: "var(--nl-bg-elevated)",
color: "var(--nl-text-primary)",
border: "1px solid var(--nl-border-subtle)",
borderRadius: "var(--nl-radius-md)",
resize: "vertical",
fontFamily: "inherit",
fontSize: "0.875rem",
}}
/> />
</section> </PanelBody>
</Panel>
{featuredProposal && !batchMode ? ( {featuredProposal && !batchMode ? (
<ProposalReviewCard <ProposalReviewCard

View File

@ -80,8 +80,7 @@ button {
.app-shell { .app-shell {
min-height: 100vh; min-height: 100vh;
display: grid; padding-left: 280px;
grid-template-columns: 280px 1fr;
} }
.sidebar { .sidebar {
@ -95,6 +94,10 @@ button {
padding: var(--nl-space-8); padding: var(--nl-space-8);
} }
.app-shell > .sidebar {
width: 280px;
}
.surface-card { .surface-card {
border: 1px solid var(--nl-border-default); border: 1px solid var(--nl-border-default);
border-radius: var(--nl-radius-md); border-radius: var(--nl-radius-md);
@ -135,7 +138,7 @@ button {
@media (max-width: 980px) { @media (max-width: 980px) {
.app-shell { .app-shell {
grid-template-columns: 1fr; padding-left: 0;
} }
.sidebar { .sidebar {

View File

@ -1,23 +1,29 @@
import { Panel, PanelHeader, PanelTitle, StatusBadge, Timeline } from "@/components/ui/Primitives";
import type { AgentTimelineItem } from "@/lib/types"; import type { AgentTimelineItem } from "@/lib/types";
export function AgentTimeline({ items }: { items: AgentTimelineItem[] }) { export function AgentTimeline({ items }: { items: AgentTimelineItem[] }) {
return ( return (
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-4)" }}> <Panel>
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", alignItems: "center" }}> <PanelHeader>
<div style={{ fontWeight: 700 }}>Agent activity timeline</div> <PanelTitle>Agent activity timeline</PanelTitle>
<span className="badge">review UX</span> <StatusBadge status="review">review UX</StatusBadge>
</div> </PanelHeader>
{items.map((item) => ( <Timeline
<div key={item.id} className="surface-muted" style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}> items={items.map((item) => ({
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", flexWrap: "wrap" }}> id: item.id,
<strong>{item.actor}</strong> title: item.actor,
<span style={{ color: "var(--nl-text-secondary)" }}>{item.timestamp}</span> description: (
</div> <span>
<div>{item.action}</div> {item.action}
<div style={{ color: "var(--nl-text-secondary)" }}>{item.summary}</div> <br />
<div className="badge" style={{ width: "fit-content" }}>{item.status}</div> {item.summary}
</div> </span>
))} ),
</section> meta: item.timestamp,
status: item.status,
tone: item.status === "rejected" ? "danger" : item.status === "approved" ? "success" : "info",
}))}
/>
</Panel>
); );
} }

View File

@ -1,3 +1,5 @@
import { Button, DiffCard, Panel, PanelHeader, PanelTitle, StatusBadge } from "@/components/ui/Primitives";
export function ProposalReviewCard({ export function ProposalReviewCard({
title, title,
before, before,
@ -14,51 +16,33 @@ export function ProposalReviewCard({
isSubmitting?: boolean; isSubmitting?: boolean;
}) { }) {
return ( return (
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-4)" }}> <Panel>
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", alignItems: "center" }}> <PanelHeader>
<div style={{ fontWeight: 700 }}>{title}</div> <PanelTitle>{title}</PanelTitle>
<div style={{ display: "flex", gap: "var(--nl-space-2)", alignItems: "center", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "var(--nl-space-2)", alignItems: "center", flexWrap: "wrap" }}>
<span className="badge">before / after</span> <StatusBadge status="review">before / after</StatusBadge>
{onReject ? ( {onReject ? (
<button <Button
type="button" type="button"
className="surface-muted" variant="destructive"
onClick={onReject} onClick={onReject}
disabled={isSubmitting} disabled={isSubmitting}
style={{ border: "1px solid var(--nl-border-subtle)", borderRadius: "var(--nl-radius-md)", padding: "8px 12px" }}
> >
Reject Reject
</button> </Button>
) : null} ) : null}
{onApprove ? ( {onApprove ? (
<button <Button
type="button" type="button"
onClick={onApprove} onClick={onApprove}
disabled={isSubmitting} disabled={isSubmitting}
style={{
border: "none",
borderRadius: "var(--nl-radius-md)",
padding: "8px 12px",
background: "var(--nl-accent-primary)",
color: "var(--nl-text-primary)",
fontWeight: 600,
}}
> >
Approve Approve
</button> </Button>
) : null} ) : null}
</div> </div>
</div> </PanelHeader>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))", gap: "var(--nl-space-4)" }}> <DiffCard before={before} after={after} />
<div className="surface-muted" style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}> </Panel>
<strong>Before</strong>
<div style={{ color: "var(--nl-text-secondary)", whiteSpace: "pre-wrap" }}>{before}</div>
</div>
<div className="surface-muted" style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}>
<strong>After</strong>
<div style={{ color: "var(--nl-text-secondary)", whiteSpace: "pre-wrap" }}>{after}</div>
</div>
</div>
</section>
); );
} }