1f562fd633
Two fixes from real usage: - Child-task creation is now gated to story-producing skills (spec-writing, story-breakdown). A code/design/test agent's output is the artifact — a numbered list in it (e.g. file names from an engineer) is no longer mistaken for child stories. - The review-inbox badge now updates without a refresh: it polls more often (6s), refetches on window focus, and reacts to a REVIEWS_CHANGED event the board fires after Run (with a couple of delayed pulses to catch the ~5s completion) and the review page fires after approve / send back. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
347 lines
13 KiB
TypeScript
347 lines
13 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
|
import { ChevronDown, ChevronRight, Clock, Cpu, ScrollText, ShieldAlert, Wrench } 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 { REVIEWS_CHANGED } from '@/components/AppShell'
|
|
import { AgentFace } from '@/components/AgentFace'
|
|
import { MarkdownEditor } from '@/components/MarkdownEditor'
|
|
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
|
|
}
|
|
|
|
interface RunDetail {
|
|
status: string
|
|
output: string | null
|
|
prompt: string | null
|
|
error: string | null
|
|
trace: string | null
|
|
resultJson: string | null
|
|
latencyMs: number | null
|
|
}
|
|
|
|
interface RunTrace {
|
|
agent?: string
|
|
autonomy?: string
|
|
skills?: string[]
|
|
tools?: string[]
|
|
docs?: string[]
|
|
memories?: number
|
|
product?: { productId?: string | null; identity?: boolean }
|
|
task?: { taskType?: string }
|
|
}
|
|
|
|
interface RunResult {
|
|
action?: string
|
|
risk?: string
|
|
skill?: string
|
|
toolCalls?: { tool: string; server?: string | null; ok: boolean }[]
|
|
}
|
|
|
|
function parseJson<T>(value: string | null | undefined): T | null {
|
|
if (!value) return null
|
|
try {
|
|
return JSON.parse(value) as T
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
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. See the action, the result, and the run log before
|
|
you approve. 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 [showLog, setShowLog] = useState(false)
|
|
const [run, setRun] = useState<RunDetail | null>(null)
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
const destructive = item.risk.toLowerCase() === 'destructive'
|
|
const childCount = childrenText.split('\n').map((l) => l.trim()).filter(Boolean).length
|
|
|
|
// Lazily pull the full run (latency, tool calls, raw output, assembled prompt) the first time the
|
|
// approver opens the log.
|
|
const toggleLog = async () => {
|
|
const next = !showLog
|
|
setShowLog(next)
|
|
if (next && !run) {
|
|
try {
|
|
setRun(await api.get<RunDetail>(`/api/assembler/runs/${item.agentRunId}`))
|
|
} catch {
|
|
// The run row may be gone; the assembly trace on the item still renders below.
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
window.dispatchEvent(new Event(REVIEWS_CHANGED))
|
|
} catch (err) {
|
|
toast.error((err as Error).message)
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center gap-3">
|
|
<AgentFace size="md" monogram={item.agentId} state="review" className="shrink-0" />
|
|
<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">
|
|
{/* Action — what approving will actually do. */}
|
|
<div
|
|
className={`rounded-lg border px-3 py-2 text-sm ${
|
|
destructive ? 'border-destructive/40 bg-destructive/10 text-destructive' : 'border-primary/30 bg-primary/5'
|
|
}`}
|
|
>
|
|
<span className="font-medium capitalize">{item.actionKind.replace(/-/g, ' ')}</span> ·{' '}
|
|
{destructive
|
|
? 'Destructive — always held for a human.'
|
|
: `On approve, this artifact is written to the board${childCount ? ` and ${childCount} child task${childCount === 1 ? '' : 's'} created` : ''}.`}
|
|
</div>
|
|
|
|
{/* Result — the proposed artifact + child tasks (both editable). */}
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor={`content-${item.id}`}>Result · proposed artifact</Label>
|
|
<MarkdownEditor id={`content-${item.id}`} value={content} onChange={setContent} rows={6} mono />
|
|
</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>
|
|
|
|
{/* Run log — how the agent got here. */}
|
|
<button
|
|
type="button"
|
|
onClick={toggleLog}
|
|
className="flex items-center gap-1.5 self-start text-xs font-medium text-primary hover:underline"
|
|
>
|
|
{showLog ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
|
|
<ScrollText className="size-3.5" /> Run log
|
|
</button>
|
|
{showLog && <RunLog item={item} run={run} />}
|
|
|
|
<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 RunLog({ item, run }: { item: ReviewItem; run: RunDetail | null }) {
|
|
// The assembly trace is on the review item; the run adds latency, tool-call outcomes, and raw output.
|
|
const trace = parseJson<RunTrace>(run?.trace ?? item.trace)
|
|
const result = parseJson<RunResult>(run?.resultJson)
|
|
const [showRaw, setShowRaw] = useState(false)
|
|
const [showPrompt, setShowPrompt] = useState(false)
|
|
const toolCalls = result?.toolCalls ?? []
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2 rounded-lg border bg-muted/30 p-3 text-xs">
|
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground">
|
|
<span className="inline-flex items-center gap-1">
|
|
<Cpu className="size-3.5" /> {trace?.agent ?? 'agent'} · {trace?.autonomy ?? '—'}
|
|
</span>
|
|
{run?.latencyMs != null && (
|
|
<span className="inline-flex items-center gap-1">
|
|
<Clock className="size-3.5" /> {(run.latencyMs / 1000).toFixed(1)}s
|
|
</span>
|
|
)}
|
|
{trace?.product?.identity && <span>· product identity included</span>}
|
|
{typeof trace?.memories === 'number' && <span>· {trace.memories} memory hit{trace.memories === 1 ? '' : 's'}</span>}
|
|
</div>
|
|
|
|
<LogRow label="Skills applied" value={trace?.skills?.length ? trace.skills.join(', ') : 'none'} />
|
|
{trace?.tools?.length ? <LogRow label="Tools available" value={trace.tools.join(', ')} /> : null}
|
|
{trace?.docs?.length ? <LogRow label="Docs" value={trace.docs.join(', ')} /> : null}
|
|
|
|
<div className="flex gap-2">
|
|
<span className="w-28 shrink-0 font-medium text-foreground">Tools called</span>
|
|
<span className="min-w-0 flex-1">
|
|
{toolCalls.length === 0 ? (
|
|
<span className="text-muted-foreground">none</span>
|
|
) : (
|
|
<span className="flex flex-col gap-0.5">
|
|
{toolCalls.map((t, i) => (
|
|
<span key={i} className="inline-flex items-center gap-1">
|
|
<Wrench className="size-3" /> {t.tool}
|
|
{t.server ? ` · ${t.server}` : ''}
|
|
<Badge variant={t.ok ? 'outline' : 'destructive'} className="ml-1 h-4 px-1.5 text-[10px]">
|
|
{t.ok ? 'ok' : 'failed'}
|
|
</Badge>
|
|
</span>
|
|
))}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
|
|
{run?.error && <LogRow label="Error" value={run.error} />}
|
|
|
|
{run?.output && (
|
|
<div className="flex flex-col gap-1">
|
|
<button type="button" onClick={() => setShowRaw((v) => !v)} className="flex items-center gap-1 self-start font-medium text-foreground hover:underline">
|
|
{showRaw ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />} Raw model output
|
|
</button>
|
|
{showRaw && <pre className="max-h-48 overflow-auto rounded bg-background/60 p-2 whitespace-pre-wrap">{run.output}</pre>}
|
|
</div>
|
|
)}
|
|
|
|
{run?.prompt && (
|
|
<div className="flex flex-col gap-1">
|
|
<button type="button" onClick={() => setShowPrompt((v) => !v)} className="flex items-center gap-1 self-start font-medium text-foreground hover:underline">
|
|
{showPrompt ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />} Assembled prompt
|
|
</button>
|
|
{showPrompt && <pre className="max-h-56 overflow-auto rounded bg-background/60 p-2 whitespace-pre-wrap">{run.prompt}</pre>}
|
|
</div>
|
|
)}
|
|
|
|
{!run && !trace && <span className="text-muted-foreground">Loading run…</span>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function LogRow({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="flex gap-2">
|
|
<span className="w-28 shrink-0 font-medium text-foreground">{label}</span>
|
|
<span className="min-w-0 flex-1 break-words">{value}</span>
|
|
</div>
|
|
)
|
|
}
|