/** * Annotation Review (#146) * * Route: /review/:queueId * * Keyboard-driven reviewer over the existing /api/annotations backend (#122): * claim → score → next. Submitting writes exactly one human_score (annotator * identity is set server-side); the score then appears on the session/trace. * * Shortcuts: p=pass · f=fail · n=needs review · Enter=submit · s=skip · j/k=next/prev */ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Link, useParams, useNavigate } from 'react-router-dom'; import { useApi } from '../hooks/useApi'; import { getQueue, submitScore, skipItem, type AnnotationItem, type HumanScoreRequest } from '../api/annotations'; type Verdict = 'pass' | 'fail' | 'needs_review' | ''; function countByStatus(items: AnnotationItem[]): Record { const c: Record = { pending: 0, in_review: 0, scored: 0, skipped: 0 }; for (const i of items) c[i.status] = (c[i.status] ?? 0) + 1; return c; } export function AnnotationReview(): React.ReactElement { const { queueId = '' } = useParams(); const navigate = useNavigate(); const { data, loading, error } = useApi(() => getQueue(queueId), [queueId]); const queue = data?.queue; const allItems = useMemo(() => data?.items ?? [], [data]); // Reviewable set fixed at load so advancing isn't disturbed by re-renders. const reviewable = useMemo( () => allItems.filter((i) => i.status === 'pending' || i.status === 'in_review'), [allItems], ); const counts = useMemo(() => countByStatus(allItems), [allItems]); const evaluatorId = typeof queue?.config?.evaluatorId === 'string' ? (queue.config.evaluatorId as string) : undefined; const [idx, setIdx] = useState(0); const current: AnnotationItem | undefined = reviewable[idx]; // Score form state const [verdict, setVerdict] = useState(''); const [score, setScore] = useState(''); const [reasoning, setReasoning] = useState(''); const [labels, setLabels] = useState(''); const [submitting, setSubmitting] = useState(false); const [formError, setFormError] = useState(null); const [submittedCount, setSubmittedCount] = useState(0); const [skippedCount, setSkippedCount] = useState(0); const resetForm = useCallback(() => { setVerdict(''); setScore(''); setReasoning(''); setLabels(''); setFormError(null); }, []); const submit = useCallback(async () => { if (!current || submitting) return; const body: HumanScoreRequest = {}; if (verdict) { body.verdict = verdict; if (verdict === 'pass') body.passed = true; if (verdict === 'fail') body.passed = false; } if (score.trim() !== '') { const n = Number(score); if (!Number.isNaN(n)) body.score = Math.max(0, Math.min(1, n)); } if (reasoning.trim()) body.reasoning = reasoning.trim(); const labelList = labels.split(',').map((s) => s.trim()).filter(Boolean); if (labelList.length) body.labels = labelList; if (evaluatorId) body.evaluatorId = evaluatorId; if (current.traceId) body.traceId = current.traceId; if (body.score === undefined && body.verdict === undefined && body.passed === undefined) { setFormError('Provide a score, verdict, or pass/fail.'); return; } setSubmitting(true); setFormError(null); try { await submitScore(current.id, body); setSubmittedCount((n) => n + 1); resetForm(); setIdx((i) => i + 1); } catch (e) { setFormError(e instanceof Error ? e.message : String(e)); } finally { setSubmitting(false); } }, [current, submitting, verdict, score, reasoning, labels, evaluatorId, resetForm]); const skip = useCallback(async () => { if (!current || submitting) return; setSubmitting(true); try { await skipItem(current.id); setSkippedCount((n) => n + 1); resetForm(); setIdx((i) => i + 1); } catch (e) { setFormError(e instanceof Error ? e.message : String(e)); } finally { setSubmitting(false); } }, [current, submitting, resetForm]); // Keyboard shortcuts (ignore single-letter keys while typing in a field). useEffect(() => { const handler = (e: KeyboardEvent): void => { const tag = (e.target as HTMLElement)?.tagName; const typing = tag === 'INPUT' || tag === 'TEXTAREA'; if (e.key === 'Enter' && (!typing || e.ctrlKey || e.metaKey)) { e.preventDefault(); void submit(); return; } if (typing) return; if (e.key === 'p') setVerdict('pass'); else if (e.key === 'f') setVerdict('fail'); else if (e.key === 'n') setVerdict('needs_review'); else if (e.key === 's') void skip(); else if (e.key === 'j' || e.key === 'ArrowDown') setIdx((i) => Math.min(i + 1, reviewable.length)); else if (e.key === 'k' || e.key === 'ArrowUp') setIdx((i) => Math.max(i - 1, 0)); }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [submit, skip, reviewable.length]); if (loading) return
Loading queue…
; if (error) return
Failed to load queue: {error}
; if (!queue) return
Queue not found.
; const reviewedTotal = counts.scored + submittedCount; const progressTotal = allItems.length || 1; return (

{queue.name}

{queue.description &&

{queue.description}

}
{/* Progress */}
{reviewedTotal} scored · {skippedCount + counts.skipped} skipped · {counts.pending + counts.in_review} in queue {Math.round((reviewedTotal / progressTotal) * 100)}%
{!current ? (
🎉 Nothing left to review in this queue.
) : (
Item {idx + 1} of {reviewable.length} · status {current.status} {current.assignee && <> · assigned to {current.assignee}}
Open session ↗
Session {current.sessionId}
{current.traceId && (
Trace {current.traceId}
)}
{/* Verdict */}
Verdict
{([ ['pass', 'Pass (p)', 'bg-green-600'], ['fail', 'Fail (f)', 'bg-red-600'], ['needs_review', 'Needs review (n)', 'bg-yellow-500'], ] as const).map(([v, label, color]) => ( ))}
{/* Numeric score */}
setScore(e.target.value)} className="w-32 border border-gray-300 rounded px-2 py-1 text-sm" />
{/* Reasoning */}