Files
Teamup/client/src/pages/ReviewsPage.tsx
T
soroush.asadi 1f562fd633 Fix: child tasks only from spec/breakdown agents; live review badge
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>
2026-06-16 22:22:57 +03:30

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>
)
}