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 { 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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user