Files
Teamup/client/src/pages/ReviewsPage.tsx
T

225 lines
7.5 KiB
TypeScript
Raw Normal View History

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 { diffWords } from '@/lib/diff'
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>
{content !== item.content && (
<div className="flex flex-col gap-2">
<Label>Your edits (vs the proposal)</Label>
<div className="max-h-40 overflow-auto whitespace-pre-wrap rounded-lg border bg-muted/40 p-3 text-xs leading-relaxed">
{diffWords(item.content, content).map((segment, i) =>
segment.kind === 'same' ? (
<span key={i}>{segment.text}</span>
) : segment.kind === 'removed' ? (
<del key={i} className="rounded bg-destructive/15 text-destructive">
{segment.text}
</del>
) : (
<ins key={i} className="rounded bg-seat-ai/15 font-medium text-primary no-underline">
{segment.text}
</ins>
),
)}
</div>
</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
}
}