Review inbox: show each AI action, result, and the run log

Restructures each held item into Action -> Result -> Run log:
- Action: a clear statement of what approving does (write artifact + N child tasks),
  with a destructive warning where relevant.
- Result: the editable proposed artifact + child tasks (with the edit diff).
- Run log: lazily fetches the AgentRun and shows latency, the agent/autonomy, skills
  applied, available + actually-called tools (with ok/failed), memory hits, product-
  identity inclusion, and collapsible raw model output + assembled prompt.

Enriches the assembler run endpoint (Trace, ResultJson, LatencyMs, timestamps) so the
approver can see exactly how the agent reached its result before deciding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-15 23:40:02 +03:30
parent 20a1a0dee4
commit 8ee60c1dfa
3 changed files with 172 additions and 46 deletions
+164 -44
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { ChevronDown, ChevronRight, ShieldAlert } from 'lucide-react' import { ChevronDown, ChevronRight, Clock, Cpu, ScrollText, ShieldAlert, Wrench } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell' import { AppShell } from '@/components/AppShell'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -30,6 +30,43 @@ interface ReviewItem {
createdAtUtc: 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() { export function ReviewsPage() {
const organizationId = useAuth((s) => s.organizationId) const organizationId = useAuth((s) => s.organizationId)
const [items, setItems] = useState<ReviewItem[] | null>(null) const [items, setItems] = useState<ReviewItem[] | null>(null)
@@ -54,7 +91,8 @@ export function ReviewsPage() {
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight">Review inbox</h1> <h1 className="text-2xl font-semibold tracking-tight">Review inbox</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Held agent actions awaiting your decision. Edit before approving your edits feed the metric. Held agent actions awaiting your decision. See the action, the result, and the run log before
you approve. Your edits feed the metric.
</p> </p>
</div> </div>
@@ -90,19 +128,32 @@ export function ReviewsPage() {
function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: string) => void }) { function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: string) => void }) {
const [content, setContent] = useState(item.content) const [content, setContent] = useState(item.content)
const [childrenText, setChildrenText] = useState(item.childTitles.join('\n')) const [childrenText, setChildrenText] = useState(item.childTitles.join('\n'))
const [showTrace, setShowTrace] = useState(false) const [showLog, setShowLog] = useState(false)
const [run, setRun] = useState<RunDetail | null>(null)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const destructive = item.risk.toLowerCase() === 'destructive' 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') { async function decide(action: 'approve' | 'sendback') {
setBusy(true) setBusy(true)
try { try {
if (action === 'approve') { if (action === 'approve') {
const childTitles = childrenText const childTitles = childrenText.split('\n').map((line) => line.trim()).filter(Boolean)
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
const result = await api.post<{ editDistance: number | null; decision: string }>( const result = await api.post<{ editDistance: number | null; decision: string }>(
`/api/governance/reviews/${item.id}/approve`, `/api/governance/reviews/${item.id}/approve`,
{ content, childTitles }, { content, childTitles },
@@ -138,23 +189,28 @@ function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: str
{destructive && <ShieldAlert />} {destructive && <ShieldAlert />}
{item.risk} {item.risk}
</Badge> </Badge>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">{new Date(item.createdAtUtc).toLocaleString()}</span>
{new Date(item.createdAtUtc).toLocaleString()}
</span>
</div> </div>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <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"> <div className="flex flex-col gap-2">
<Label htmlFor={`content-${item.id}`}>Proposed artifact</Label> <Label htmlFor={`content-${item.id}`}>Result · proposed artifact</Label>
<MarkdownEditor <MarkdownEditor id={`content-${item.id}`} value={content} onChange={setContent} rows={6} mono />
id={`content-${item.id}`}
value={content}
onChange={setContent}
rows={6}
mono
/>
</div> </div>
{content !== item.content && ( {content !== item.content && (
@@ -165,13 +221,9 @@ function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: str
segment.kind === 'same' ? ( segment.kind === 'same' ? (
<span key={i}>{segment.text}</span> <span key={i}>{segment.text}</span>
) : segment.kind === 'removed' ? ( ) : segment.kind === 'removed' ? (
<del key={i} className="rounded bg-destructive/15 text-destructive"> <del key={i} className="rounded bg-destructive/15 text-destructive">{segment.text}</del>
{segment.text}
</del>
) : ( ) : (
<ins key={i} className="rounded bg-seat-ai/15 font-medium text-primary no-underline"> <ins key={i} className="rounded bg-seat-ai/15 font-medium text-primary no-underline">{segment.text}</ins>
{segment.text}
</ins>
), ),
)} )}
</div> </div>
@@ -189,36 +241,104 @@ function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: str
/> />
</div> </div>
{/* Run log — how the agent got here. */}
<button <button
type="button" type="button"
onClick={() => setShowTrace((v) => !v)} onClick={toggleLog}
className="flex items-center gap-1 self-start text-xs font-medium text-primary hover:underline" className="flex items-center gap-1.5 self-start text-xs font-medium text-primary hover:underline"
> >
{showTrace ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />} {showLog ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
Reasoning trace <ScrollText className="size-3.5" /> Run log
</button> </button>
{showTrace && ( {showLog && <RunLog item={item} run={run} />}
<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"> <div className="flex items-center justify-end gap-2">
<Button variant="outline" disabled={busy} onClick={() => decide('sendback')}> <Button variant="outline" disabled={busy} onClick={() => decide('sendback')}>Send back</Button>
Send back <Button disabled={busy} onClick={() => decide('approve')}>{busy ? 'Working…' : 'Approve'}</Button>
</Button>
<Button disabled={busy} onClick={() => decide('approve')}>
{busy ? 'Working…' : 'Approve'}
</Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
) )
} }
function formatTrace(trace: string | null): string { function RunLog({ item, run }: { item: ReviewItem; run: RunDetail | null }) {
if (!trace) return 'No trace captured.' // The assembly trace is on the review item; the run adds latency, tool-call outcomes, and raw output.
try { const trace = parseJson<RunTrace>(run?.trace ?? item.trace)
return JSON.stringify(JSON.parse(trace), null, 2) const result = parseJson<RunResult>(run?.resultJson)
} catch { const [showRaw, setShowRaw] = useState(false)
return trace 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>
)
} }
@@ -12,7 +12,12 @@ internal sealed record RunResponse(
string? ActionRisk, string? ActionRisk,
string? Prompt, string? Prompt,
string? Output, string? Output,
string? Error); string? Error,
string? Trace,
string? ResultJson,
long? LatencyMs,
DateTimeOffset CreatedAtUtc,
DateTimeOffset? CompletedAtUtc);
internal sealed record AgentActivityResponse( internal sealed record AgentActivityResponse(
Guid AgentId, Guid AgentId,
@@ -85,5 +85,6 @@ internal static class AssemblerEndpoints
private static RunResponse ToResponse(AgentRun run) => new( private static RunResponse ToResponse(AgentRun run) => new(
run.Id, run.SeatId, run.WorkItemId, run.AgentId, run.Status.ToString(), run.Id, run.SeatId, run.WorkItemId, run.AgentId, run.Status.ToString(),
run.ActionType, run.ActionRisk, run.Prompt, run.Output, run.Error); run.ActionType, run.ActionRisk, run.Prompt, run.Output, run.Error,
run.Trace, run.ResultJson, run.LatencyMs, run.CreatedAtUtc, run.CompletedAtUtc);
} }