feat(ui): migrate review workflow primitives
This commit is contained in:
parent
1784f72d70
commit
c79aa2b6fd
@ -5,6 +5,17 @@ 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 { approveReviewItem, batchReviewItems, listAgentTimeline, listApprovalQueue, rejectReviewItem } from "@/lib/review-client";
|
||||
import { toast } from "@/lib/toast";
|
||||
import type { AgentTimelineItem, ApprovalQueueItem } from "@/lib/types";
|
||||
@ -175,105 +186,88 @@ export default function ReviewsPage() {
|
||||
<AppShell
|
||||
title="Agent review"
|
||||
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)" }}>
|
||||
<aside className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-4)" }}>
|
||||
<div style={{ fontWeight: 700 }}>Operator workflows</div>
|
||||
<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)}
|
||||
className="surface-muted"
|
||||
style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}
|
||||
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" }}>
|
||||
<span className="badge">{workflow.status}</span>
|
||||
<span className="badge">Queue: {workflow.queueCount}</span>
|
||||
<span className="badge">SLA {workflow.sla}</span>
|
||||
<StatusBadge status={workflow.status === "healthy" ? "approved" : "pending"}>
|
||||
{workflow.status}
|
||||
</StatusBadge>
|
||||
<Badge>Queue: {workflow.queueCount}</Badge>
|
||||
<Badge>SLA {workflow.sla}</Badge>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
</Panel>
|
||||
|
||||
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-4)" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", flexWrap: "wrap" }}>
|
||||
<div style={{ fontWeight: 700 }}>Approval queue</div>
|
||||
<Panel>
|
||||
<PanelHeader>
|
||||
<PanelTitle>Approval queue</PanelTitle>
|
||||
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap", alignItems: "center" }}>
|
||||
{batchMode ? (
|
||||
<>
|
||||
<span className="badge">{selectedBatchIds.size} selected</span>
|
||||
<button type="button" className="badge" onClick={clearBatch} style={{ cursor: "pointer" }}>Clear</button>
|
||||
<Badge>{selectedBatchIds.size} selected</Badge>
|
||||
<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>
|
||||
</PanelHeader>
|
||||
{error ? <div style={{ color: "var(--nl-text-secondary)" }}>{error}</div> : null}
|
||||
|
||||
{batchMode ? (
|
||||
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
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}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => void handleBatchDecision("rejected")}
|
||||
className="surface-muted"
|
||||
style={{
|
||||
border: "1px solid var(--nl-border-subtle)",
|
||||
borderRadius: "var(--nl-radius-md)",
|
||||
padding: "8px 16px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
variant="destructive"
|
||||
>
|
||||
Reject {selectedBatchIds.size}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
|
||||
{approvalQueue.map((item) => (
|
||||
<button
|
||||
<ListItemButton
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="surface-muted"
|
||||
selected={
|
||||
batchMode
|
||||
? selectedBatchIds.has(item.id)
|
||||
: featuredProposal?.id === item.id
|
||||
}
|
||||
onClick={() => batchMode ? toggleBatchItem(item.id) : setSelectedApprovalId(item.id)}
|
||||
style={{
|
||||
padding: "var(--nl-space-4)",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
gap: "var(--nl-space-3)",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
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 }}>
|
||||
@ -282,38 +276,31 @@ export default function ReviewsPage() {
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
|
||||
{batchMode ? (
|
||||
<span className="badge">{selectedBatchIds.has(item.id) ? "selected" : "unselected"}</span>
|
||||
<Badge>{selectedBatchIds.has(item.id) ? "selected" : "unselected"}</Badge>
|
||||
) : null}
|
||||
<span className="badge">{item.severity}</span>
|
||||
<span className="badge">{item.status}</span>
|
||||
<Badge>{item.severity}</Badge>
|
||||
<StatusBadge status={item.status === "proposed" ? "proposed" : "pending"}>
|
||||
{item.status}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</button>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</Panel>
|
||||
</section>
|
||||
|
||||
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}>
|
||||
<label htmlFor="review-note" style={{ fontWeight: 600 }}>Review note</label>
|
||||
<textarea
|
||||
<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}
|
||||
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 ? (
|
||||
<ProposalReviewCard
|
||||
|
||||
@ -80,8 +80,7 @@ button {
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
padding-left: 280px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
@ -95,6 +94,10 @@ button {
|
||||
padding: var(--nl-space-8);
|
||||
}
|
||||
|
||||
.app-shell > .sidebar {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.surface-card {
|
||||
border: 1px solid var(--nl-border-default);
|
||||
border-radius: var(--nl-radius-md);
|
||||
@ -135,7 +138,7 @@ button {
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
|
||||
@ -1,23 +1,29 @@
|
||||
import { Panel, PanelHeader, PanelTitle, StatusBadge, Timeline } from "@/components/ui/Primitives";
|
||||
import type { AgentTimelineItem } from "@/lib/types";
|
||||
|
||||
export function AgentTimeline({ items }: { items: AgentTimelineItem[] }) {
|
||||
return (
|
||||
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-4)" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", alignItems: "center" }}>
|
||||
<div style={{ fontWeight: 700 }}>Agent activity timeline</div>
|
||||
<span className="badge">review UX</span>
|
||||
</div>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="surface-muted" style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", flexWrap: "wrap" }}>
|
||||
<strong>{item.actor}</strong>
|
||||
<span style={{ color: "var(--nl-text-secondary)" }}>{item.timestamp}</span>
|
||||
</div>
|
||||
<div>{item.action}</div>
|
||||
<div style={{ color: "var(--nl-text-secondary)" }}>{item.summary}</div>
|
||||
<div className="badge" style={{ width: "fit-content" }}>{item.status}</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
<Panel>
|
||||
<PanelHeader>
|
||||
<PanelTitle>Agent activity timeline</PanelTitle>
|
||||
<StatusBadge status="review">review UX</StatusBadge>
|
||||
</PanelHeader>
|
||||
<Timeline
|
||||
items={items.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.actor,
|
||||
description: (
|
||||
<span>
|
||||
{item.action}
|
||||
<br />
|
||||
{item.summary}
|
||||
</span>
|
||||
),
|
||||
meta: item.timestamp,
|
||||
status: item.status,
|
||||
tone: item.status === "rejected" ? "danger" : item.status === "approved" ? "success" : "info",
|
||||
}))}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { Button, DiffCard, Panel, PanelHeader, PanelTitle, StatusBadge } from "@/components/ui/Primitives";
|
||||
|
||||
export function ProposalReviewCard({
|
||||
title,
|
||||
before,
|
||||
@ -14,51 +16,33 @@ export function ProposalReviewCard({
|
||||
isSubmitting?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-4)" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", alignItems: "center" }}>
|
||||
<div style={{ fontWeight: 700 }}>{title}</div>
|
||||
<Panel>
|
||||
<PanelHeader>
|
||||
<PanelTitle>{title}</PanelTitle>
|
||||
<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 ? (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="surface-muted"
|
||||
variant="destructive"
|
||||
onClick={onReject}
|
||||
disabled={isSubmitting}
|
||||
style={{ border: "1px solid var(--nl-border-subtle)", borderRadius: "var(--nl-radius-md)", padding: "8px 12px" }}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
{onApprove ? (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onApprove}
|
||||
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
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))", gap: "var(--nl-space-4)" }}>
|
||||
<div className="surface-muted" style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}>
|
||||
<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>
|
||||
</PanelHeader>
|
||||
<DiffCard before={before} after={after} />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user