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:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user