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:
@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router'
|
|||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
import { BoardPage } from '@/pages/BoardPage'
|
import { BoardPage } from '@/pages/BoardPage'
|
||||||
import { LoginPage } from '@/pages/LoginPage'
|
import { LoginPage } from '@/pages/LoginPage'
|
||||||
|
import { ReviewsPage } from '@/pages/ReviewsPage'
|
||||||
import { SeatsPage } from '@/pages/SeatsPage'
|
import { SeatsPage } from '@/pages/SeatsPage'
|
||||||
import { useAuth } from '@/store/auth'
|
import { useAuth } from '@/store/auth'
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ export default function App() {
|
|||||||
<Route path="/login" element={token ? <Navigate to="/" replace /> : <LoginPage />} />
|
<Route path="/login" element={token ? <Navigate to="/" replace /> : <LoginPage />} />
|
||||||
<Route path="/" element={token ? <BoardPage /> : <Navigate to="/login" replace />} />
|
<Route path="/" element={token ? <BoardPage /> : <Navigate to="/login" replace />} />
|
||||||
<Route path="/seats" element={token ? <SeatsPage /> : <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 />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<Toaster richColors position="top-right" />
|
<Toaster richColors position="top-right" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { Link, useLocation } from 'react-router'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { cn } from '@/lib/utils'
|
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">
|
<nav className="flex flex-1 flex-col gap-1 p-3">
|
||||||
<NavItem icon={LayoutDashboard} label="Board" to="/" />
|
<NavItem icon={LayoutDashboard} label="Board" to="/" />
|
||||||
<NavItem icon={Bot} label="AI seats" to="/seats" />
|
<NavItem icon={Bot} label="AI seats" to="/seats" />
|
||||||
|
<NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" />
|
||||||
<NavItem icon={Inbox} label="Cartable" muted />
|
<NavItem icon={Inbox} label="Cartable" muted />
|
||||||
<NavItem icon={Network} label="Org chart" muted />
|
<NavItem icon={Network} label="Org chart" muted />
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user