M5 UI: the review inbox — approve / edit-and-approve / send back

The trust centerpiece: /reviews lists held agent actions for the scopes the caller may
approve. Each card shows the agent badge, action kind + risk (destructive flagged red),
an EDITABLE proposed artifact and child-task list (edits feed the edit-distance metric),
an expandable reasoning trace (pretty-printed), and Approve / Send back. Toasts surface
the recorded edit distance. New shadcn-style Textarea; nav gains "Review inbox".

Verified: npm run build green (TS strict, 1893 modules).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-10 08:53:43 +03:30
parent d83ad87151
commit 7e993de943
4 changed files with 224 additions and 1 deletions
+2
View File
@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router'
import { Toaster } from '@/components/ui/sonner'
import { BoardPage } from '@/pages/BoardPage'
import { LoginPage } from '@/pages/LoginPage'
import { ReviewsPage } from '@/pages/ReviewsPage'
import { SeatsPage } from '@/pages/SeatsPage'
import { useAuth } from '@/store/auth'
@@ -14,6 +15,7 @@ export default function App() {
<Route path="/login" element={token ? <Navigate to="/" replace /> : <LoginPage />} />
<Route path="/" element={token ? <BoardPage /> : <Navigate to="/login" replace />} />
<Route path="/seats" element={token ? <SeatsPage /> : <Navigate to="/login" replace />} />
<Route path="/reviews" element={token ? <ReviewsPage /> : <Navigate to="/login" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Toaster richColors position="top-right" />
+2 -1
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import { Link, useLocation } from 'react-router'
import { Bot, Inbox, type LucideIcon, LayoutDashboard, LogOut, Network } from 'lucide-react'
import { Bot, Inbox, type LucideIcon, LayoutDashboard, LogOut, Network, ShieldCheck } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
@@ -28,6 +28,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<nav className="flex flex-1 flex-col gap-1 p-3">
<NavItem icon={LayoutDashboard} label="Board" to="/" />
<NavItem icon={Bot} label="AI seats" to="/seats" />
<NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" />
<NavItem icon={Inbox} label="Cartable" muted />
<NavItem icon={Network} label="Org chart" muted />
</nav>
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1.5 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+202
View File
@@ -0,0 +1,202 @@
import { useCallback, useEffect, useState } from 'react'
import { ChevronDown, ChevronRight, ShieldAlert } from 'lucide-react'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { api } from '@/lib/api'
import { useAuth } from '@/store/auth'
interface ReviewItem {
id: string
teamId: string
agentRunId: string
agentId: string
workItemId: string
actionKind: string
risk: string
title: string
content: string
childTitles: string[]
trace: string | null
status: string
createdAtUtc: string
}
export function ReviewsPage() {
const organizationId = useAuth((s) => s.organizationId)
const [items, setItems] = useState<ReviewItem[] | null>(null)
const load = useCallback(async () => {
if (!organizationId) return
try {
setItems(await api.get<ReviewItem[]>(`/api/governance/reviews?organizationId=${organizationId}`))
} catch (err) {
toast.error((err as Error).message)
setItems([])
}
}, [organizationId])
useEffect(() => {
void load()
}, [load])
return (
<AppShell>
<div className="mx-auto max-w-3xl p-6">
<div className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight">Review inbox</h1>
<p className="text-sm text-muted-foreground">
Held agent actions awaiting your decision. Edit before approving your edits feed the metric.
</p>
</div>
{items === null && (
<div className="flex flex-col gap-4">
<Skeleton className="h-40 w-full" />
<Skeleton className="h-40 w-full" />
</div>
)}
{items?.length === 0 && (
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
Nothing is waiting on you. Held agent actions will appear here.
</CardContent>
</Card>
)}
<div className="flex flex-col gap-4">
{items?.map((item) => (
<ReviewCard
key={item.id}
item={item}
onDecided={(id) => setItems((s) => s?.filter((x) => x.id !== id) ?? s)}
/>
))}
</div>
</div>
</AppShell>
)
}
function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: string) => void }) {
const [content, setContent] = useState(item.content)
const [childrenText, setChildrenText] = useState(item.childTitles.join('\n'))
const [showTrace, setShowTrace] = useState(false)
const [busy, setBusy] = useState(false)
const destructive = item.risk.toLowerCase() === 'destructive'
async function decide(action: 'approve' | 'sendback') {
setBusy(true)
try {
if (action === 'approve') {
const childTitles = childrenText
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
const result = await api.post<{ editDistance: number | null; decision: string }>(
`/api/governance/reviews/${item.id}/approve`,
{ content, childTitles },
)
const distance = result.editDistance ?? 0
toast.success(
result.decision === 'EditedAndApproved'
? `Approved with edits — edit distance ${distance.toFixed(3)}`
: 'Approved as proposed',
)
} else {
await api.post(`/api/governance/reviews/${item.id}/sendback`, {})
toast.info('Sent back to the agent')
}
onDecided(item.id)
} catch (err) {
toast.error((err as Error).message)
} finally {
setBusy(false)
}
}
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-seat-ai font-semibold text-white">
AI
</span>
<div className="min-w-0 flex-1">
<CardTitle className="truncate text-base">{item.title}</CardTitle>
<div className="mt-1 flex items-center gap-2">
<Badge variant="secondary">{item.actionKind}</Badge>
<Badge variant={destructive ? 'destructive' : 'outline'}>
{destructive && <ShieldAlert />}
{item.risk}
</Badge>
<span className="text-xs text-muted-foreground">
{new Date(item.createdAtUtc).toLocaleString()}
</span>
</div>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor={`content-${item.id}`}>Proposed artifact</Label>
<Textarea
id={`content-${item.id}`}
value={content}
onChange={(e) => setContent(e.target.value)}
rows={6}
className="font-mono text-xs"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`children-${item.id}`}>Child tasks (one per line)</Label>
<Textarea
id={`children-${item.id}`}
value={childrenText}
onChange={(e) => setChildrenText(e.target.value)}
rows={4}
placeholder="No child tasks proposed — add lines to create them on approval."
/>
</div>
<button
type="button"
onClick={() => setShowTrace((v) => !v)}
className="flex items-center gap-1 self-start text-xs font-medium text-primary hover:underline"
>
{showTrace ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
Reasoning trace
</button>
{showTrace && (
<pre className="max-h-48 overflow-auto rounded-lg bg-muted p-3 text-xs">{formatTrace(item.trace)}</pre>
)}
<div className="flex items-center justify-end gap-2">
<Button variant="outline" disabled={busy} onClick={() => decide('sendback')}>
Send back
</Button>
<Button disabled={busy} onClick={() => decide('approve')}>
{busy ? 'Working…' : 'Approve'}
</Button>
</div>
</CardContent>
</Card>
)
}
function formatTrace(trace: string | null): string {
if (!trace) return 'No trace captured.'
try {
return JSON.stringify(JSON.parse(trace), null, 2)
} catch {
return trace
}
}