// ArenaPane — live view for an Arena battle. // Mirrors OrchestratorPane: header with status/winner, contestant roster // (collapsed rows, expand-one), analysis panel, cross-examination control. // // Subscribes to the coder user channel (via useCoderUserEvents → sessionEvents) // for battle_started / contestant_updated / battle_updated frames. import { useCallback, useEffect, useRef, useState } from 'react'; import { ChevronDown, ChevronRight, Loader2, MoreHorizontal, RotateCcw, Swords, Trophy, X } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api/client'; import type { ArenaState, BattleShape, ContestantShape, CrossExaminationShape, ProviderSnapshotEntry } from '@/api/types'; import { sessionEvents } from '@/hooks/sessionEvents'; import { useProviderSnapshot } from '@/hooks/useProviderSnapshot'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { cn } from '@/lib/utils'; // ─── Status dot (mirrors FlowStepStatusDot) ─────────────────────────────────── function ContestantStatusDot({ status }: { status: ContestantShape['status'] }) { if (status === 'running') { return ( ); } const cls = status === 'done' ? 'bg-emerald-500' : status === 'error' ? 'bg-destructive' : 'bg-muted-foreground/40'; // queued return ; } // ─── Lane badge ─────────────────────────────────────────────────────────────── function LaneBadge({ lane }: { lane: ContestantShape['lane'] }) { return ( {lane} ); } // ─── Duration formatter ─────────────────────────────────────────────────────── function formatDuration(ms: number | null): string { if (ms == null) return ''; const s = Math.round(ms / 1000); if (s < 60) return `${s}s`; return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`; } // ─── Live ticker for running contestants ───────────────────────────────────── function LiveDuration({ startedAt }: { startedAt: number }) { const [elapsed, setElapsed] = useState(() => Date.now() - startedAt); useEffect(() => { const id = setInterval(() => setElapsed(Date.now() - startedAt), 1000); return () => clearInterval(id); }, [startedAt]); return {formatDuration(elapsed)}; } // ─── DiffView ───────────────────────────────────────────────────────────────── function DiffView({ diff }: { diff: string }) { const lines = diff.split('\n'); return (
Diff
        {lines.map((line, i) => {
          const cls =
            line.startsWith('+') && !line.startsWith('+++')
              ? 'text-emerald-600 dark:text-emerald-400'
              : line.startsWith('-') && !line.startsWith('---')
                ? 'text-destructive'
                : line.startsWith('@@')
                  ? 'text-violet-500 dark:text-violet-400'
                  : 'text-muted-foreground';
          return (
            
              {line || ' '}
            
          );
        })}
      
); } // ─── ContestantRow ──────────────────────────────────────────────────────────── interface ContestantRowState { data: ContestantShape; output: string; startedAt: number | null; } function ContestantRow({ row, isExpanded, onToggle, isWinner, battleId, battleType, }: { row: ContestantRowState; isExpanded: boolean; onToggle: () => void; isWinner: boolean; battleId: string; battleType: 'coding' | 'qa'; }) { const { data, output, startedAt } = row; const label = `${data.identity} / ${data.model}`; // Lazy-fetch diff for coding contestants once they are done and expanded. const [diff, setDiff] = useState(null); useEffect(() => { if (!isExpanded || battleType !== 'coding' || data.status !== 'done') return; if (diff !== null) return; api.battles.getDiff(battleId, data.id) .then(({ diff: d }) => setDiff(d)) .catch(() => setDiff('')); }, [isExpanded, battleType, data.status, data.id, battleId, diff]); async function handleSetWinner(contestantId: string | null) { try { await api.battles.setWinner(battleId, { winner_contestant_id: contestantId }); } catch { // WS frame updates the badge; a failed call just leaves it unchanged } } return (
{!isWinner && ( void handleSetWinner(data.id)}> Set as winner )} {isWinner && ( void handleSetWinner(null)}> Clear winner )} {isExpanded && (
{data.token_breakdown && (
{data.token_breakdown.system > 0 && {data.token_breakdown.system}s} {data.token_breakdown.user > 0 && {data.token_breakdown.user}u} {data.token_breakdown.assistant > 0 && {data.token_breakdown.assistant}a} {data.token_breakdown.tools > 0 && {data.token_breakdown.tools}t} {data.token_breakdown.reasoning > 0 && {data.token_breakdown.reasoning}r} {data.token_breakdown.total > 0 && ∑{data.token_breakdown.total}}
)} {output.length === 0 ? (
{data.status === 'queued' ? 'Waiting to start…' : data.status === 'error' ? data.error ?? 'Error' : 'Connecting…'}
) : (
              {output}
            
)} {battleType === 'coding' && data.status === 'done' && diff && ( )}
)}
); } // ─── CrossExaminationPanel ──────────────────────────────────────────────────── function CrossExaminationPanel({ battleId, crossExams, snapshot, }: { battleId: string; crossExams: CrossExaminationShape[]; snapshot: ProviderSnapshotEntry[] | null; }) { const [identity, setIdentity] = useState(''); const [model, setModel] = useState(''); const [running, setRunning] = useState(false); const identityOptions = (snapshot ?? []) .filter((e) => e.installed && e.enabled) .map((e) => ({ value: e.name, label: e.label })); const modelOptions = (() => { const provider = (snapshot ?? []).find((e) => e.name === identity); return (provider?.models ?? []).map((m) => ({ value: m.id, label: m.label })); })(); async function handleRun() { if (!identity || !model || running) return; setRunning(true); try { await api.battles.crossExamine(battleId, { identity, model }); // The verdict arrives via battle_updated frame; ArenaPane will refetch. } catch (err) { toast.error(err instanceof Error ? err.message : 'Cross-examination failed'); } finally { setRunning(false); } } return (
Cross-examination

Challenge the results with any model. The verdict is advisory and never changes the recorded winner.

{crossExams.length > 0 && (
{crossExams.map((xe) => (
{xe.identity} / {xe.model}
{xe.verdict ? (
{xe.verdict}
) : (
Running…
)}
))}
)}
); } // ─── ArenaPane ──────────────────────────────────────────────────────────────── interface Props { state: ArenaState; projectId: string; // available for future use (e.g. file browser affordance) onClose: () => void; } export function ArenaPane({ state, onClose }: Props) { const [battle, setBattle] = useState(null); const [contestantRows, setContestantRows] = useState([]); const [crossExams, setCrossExams] = useState([]); const [analysis, setAnalysis] = useState(null); const [expandedId, setExpandedId] = useState(null); const [stopping, setStopping] = useState(false); const [reanalyzing, setReanalyzing] = useState(false); const startTimesRef = useRef>(new Map()); const snapshot = useProviderSnapshot(); // Fetch current battle state on mount / battle_id change. useEffect(() => { setBattle(null); setContestantRows([]); setCrossExams([]); setAnalysis(null); setExpandedId(null); api.battles.get(state.battle_id) .then(({ battle: b, contestants, cross_examinations }) => { setBattle(b); setContestantRows( contestants.map((c) => ({ data: c, output: '', startedAt: c.status === 'running' ? Date.now() : null, })), ); setCrossExams(cross_examinations); // Fetch analysis text if battle is already completed. if (b.status === 'completed') { api.battles.getAnalysis(state.battle_id) .then(({ text }) => setAnalysis(text)) .catch(() => {}); } // Auto-expand first running contestant. const firstRunning = contestants.find((c) => c.status === 'running'); if (firstRunning) setExpandedId(firstRunning.id); }) .catch(() => {}); }, [state.battle_id]); // Subscribe to live battle/contestant frames. useEffect(() => { return sessionEvents.subscribe((ev) => { if (ev.type === 'battle_started' && ev.battle_id === state.battle_id) { setContestantRows((prev) => { if (prev.length > 0) return prev; return ev.contestants.map((c) => ({ data: { id: c.id, battle_id: ev.battle_id, identity: c.identity, model: c.model, lane: c.lane, task_id: null, worktree_id: null, status: 'queued' as const, duration_ms: null, tokens_per_sec: null, cost_tokens: null, token_breakdown: null, result_path: null, error: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }, output: '', startedAt: null, })); }); } else if (ev.type === 'contestant_updated' && ev.battle_id === state.battle_id) { setContestantRows((prev) => prev.map((row) => { if (row.data.id !== ev.contestant_id) return row; const updatedData: ContestantShape = { ...row.data, ...(ev.status != null ? { status: ev.status } : {}), ...(ev.duration_ms != null ? { duration_ms: ev.duration_ms } : {}), ...(ev.tokens_per_sec != null ? { tokens_per_sec: ev.tokens_per_sec } : {}), ...(ev.error != null ? { error: ev.error } : {}), }; const newStartedAt = ev.status === 'running' && row.startedAt == null ? Date.now() : ev.status === 'done' || ev.status === 'error' ? null : row.startedAt; if (ev.status === 'running') { startTimesRef.current.set(ev.contestant_id, newStartedAt ?? Date.now()); setExpandedId(ev.contestant_id); } return { data: updatedData, output: ev.delta ? row.output + ev.delta : row.output, startedAt: newStartedAt, }; }), ); if (ev.battle_status) { setBattle((prev) => prev ? { ...prev, status: ev.battle_status! } : prev); } } else if (ev.type === 'battle_updated' && ev.battle_id === state.battle_id) { setBattle((prev) => { if (!prev) return prev; return { ...prev, ...(ev.status != null ? { status: ev.status } : {}), ...(ev.winner_contestant_id !== undefined ? { winner_contestant_id: ev.winner_contestant_id } : {}), }; }); if (ev.analysis_ready) { api.battles.getAnalysis(state.battle_id) .then(({ text }) => setAnalysis(text)) .catch(() => setAnalysis('Analysis ready — failed to load text.')); } if (ev.cross_exam_id) { // Refetch cross-exams to get the latest verdict. api.battles.get(state.battle_id) .then(({ cross_examinations }) => setCrossExams(cross_examinations)) .catch(() => {}); } } }); }, [state.battle_id]); const toggleExpand = useCallback((id: string) => { setExpandedId((prev) => (prev === id ? null : id)); }, []); async function handleStop() { if (stopping) return; setStopping(true); try { await api.battles.stop(state.battle_id); } catch { // non-fatal } finally { setStopping(false); } } async function handleReanalyze() { if (reanalyzing) return; setReanalyzing(true); try { await api.battles.analyze(state.battle_id); toast.success('Re-analysis triggered'); } catch (err) { toast.error(err instanceof Error ? err.message : 'Re-analysis failed'); } finally { setReanalyzing(false); } } function handleOpenResults() { if (!battle?.results_path) return; sessionEvents.emit({ type: 'open_file_in_browser', path: battle.results_path }); } function handleCopyAnalysis() { if (!analysis) return; navigator.clipboard.writeText(analysis).catch(() => toast.error('Clipboard write failed')); } const battleStatus = battle?.status ?? 'running'; const isRunning = battleStatus === 'running' || battleStatus === 'pending'; const isCompleted = battleStatus === 'completed'; const winnerId = battle?.winner_contestant_id; const winnerRow = winnerId ? contestantRows.find((r) => r.data.id === winnerId) : null; const winnerLabel = winnerRow ? `${winnerRow.data.identity} / ${winnerRow.data.model}` : null; return (
{/* Header */}
{state.prompt.length > 60 ? state.prompt.slice(0, 60) + '…' : state.prompt} {state.battle_type} {winnerLabel && ( ✓ {winnerLabel} )}
{isRunning ? ( ) : ( {battleStatus} )} {isCompleted && ( void handleReanalyze()} disabled={reanalyzing}> Re-analyze {battle?.results_path && ( Open results folder )} {analysis && ( Copy analysis )} )}
{/* Body */}
{/* Analysis panel */} {analysis && (
Analysis
{analysis}
{winnerLabel && (
Winner: {winnerLabel}
)}
)} {/* Empty state */} {contestantRows.length === 0 && !analysis && (
Starting battle…
)} {/* Contestant roster */}
{contestantRows.map((row) => ( toggleExpand(row.data.id)} isWinner={winnerId === row.data.id} battleId={state.battle_id} battleType={battle?.battle_type ?? state.battle_type} /> ))}
{/* Cross-examination panel — available after battle finishes */} {!isRunning && ( )}
); }