// 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 (
{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.
void handleRun()}
disabled={!identity || !model || running}
className="inline-flex items-center gap-1 text-xs px-2 py-1.5 rounded border border-border text-foreground hover:bg-muted disabled:opacity-50"
>
{running && }
Run
{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