// ArenaLauncherDialog — mirrors FlowLauncherDialog. // Opens via sessionEvents 'open_arena_launcher'. // Flow: pick Battle Type → write/generate prompt → add 2–6 contestants → Start. import { useCallback, useEffect, useRef, useState } from 'react'; import { Loader2, Minus, Plus, Swords, TriangleAlert, X } from 'lucide-react'; import { toast } from 'sonner'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { api } from '@/api/client'; import type { Agent, ProviderSnapshotEntry } from '@/api/types'; import { sessionEvents } from '@/hooks/sessionEvents'; import { useProviderSnapshot } from '@/hooks/useProviderSnapshot'; import { cn } from '@/lib/utils'; // ─── types ──────────────────────────────────────────────────────────────────── type BattleType = 'coding' | 'qa'; interface Contestant { key: string; // local unique key for React identity: string; model: string; } // ─── helpers ───────────────────────────────────────────────────────────────── function newContestant(): Contestant { return { key: crypto.randomUUID(), identity: '', model: '' }; } function isDuplicate(contestants: Contestant[], c: Contestant): boolean { const dups = contestants.filter( (x) => x.key !== c.key && x.identity === c.identity && x.model === c.model && x.identity !== '', ); return dups.length > 0; } function hasDuplicatePair(contestants: Contestant[]): boolean { return contestants.some((c) => isDuplicate(contestants, c)); } function localCount(battleType: BattleType, contestants: Contestant[], snapshot: ProviderSnapshotEntry[] | null): number { if (battleType === 'qa') return contestants.filter((c) => c.identity !== '').length; const boocode = snapshot?.find((e) => e.name === 'boocode'); const localModelIds = new Set(boocode?.models.map((m) => m.id) ?? []); return contestants.filter((c) => { // Match bare IDs (boocode/native) and llama-swap/-prefixed IDs used by // opencode and other external agents pointing at the local llama-swap server. return localModelIds.has(c.model) || localModelIds.has(c.model.replace(/^llama-swap\//, '')); }).length; } // ─── ContestantRow ──────────────────────────────────────────────────────────── function ContestantRow({ contestant, battleType, snapshot, agents, allContestants, onUpdate, onRemove, removable, }: { contestant: Contestant; battleType: BattleType; snapshot: ProviderSnapshotEntry[] | null; agents: Agent[]; allContestants: Contestant[]; onUpdate: (patch: Partial) => void; onRemove: () => void; removable: boolean; }) { const dup = isDuplicate(allContestants, contestant); // Identity options for Coding: installed provider names. // Identity options for Q&A: agents by id. const identityOptions = battleType === 'coding' ? (snapshot ?? []) .filter((e) => e.installed && e.enabled) .map((e) => ({ value: e.name, label: e.label })) : agents.map((a) => ({ value: a.id, label: a.name })); // Model options: for Coding use the selected provider's models; for Q&A use boocode models. const modelOptions: { value: string; label: string }[] = (() => { if (battleType === 'coding') { const provider = (snapshot ?? []).find((e) => e.name === contestant.identity); return (provider?.models ?? []).map((m) => ({ value: m.id, label: m.label })); } // Q&A: native backend only — use boocode models const boocode = (snapshot ?? []).find((e) => e.name === 'boocode'); return (boocode?.models ?? []).map((m) => ({ value: m.id, label: m.label })); })(); function handleIdentityChange(value: string) { // Reset model when identity changes so stale model doesn't persist. onUpdate({ identity: value, model: '' }); } function handleModelChange(value: string) { onUpdate({ model: value }); } return (
{dup && ( )} {removable && ( )}
); } // ─── ArenaLauncherDialog ────────────────────────────────────────────────────── export function ArenaLauncherDialog() { const [open, setOpen] = useState(false); const [projectId, setProjectId] = useState(''); const [placement, setPlacement] = useState<'new' | 'split'>('new'); const [battleType, setBattleType] = useState('coding'); const [prompt, setPrompt] = useState(''); const [contestants, setContestants] = useState(() => [ newContestant(), newContestant(), ]); const [generating, setGenerating] = useState(false); const [starting, setStarting] = useState(false); const [agents, setAgents] = useState([]); const promptRef = useRef(null); const snapshot = useProviderSnapshot(); useEffect(() => { return sessionEvents.subscribe((ev) => { if (ev.type !== 'open_arena_launcher') return; setProjectId(ev.project_id); setPlacement(ev.placement ?? 'new'); setBattleType('coding'); setPrompt(''); setContestants([newContestant(), newContestant()]); setGenerating(false); setStarting(false); setOpen(true); }); }, []); // Load agents list when dialog opens (for Q&A mode). useEffect(() => { if (!open || !projectId) return; api.agents.list(projectId) .then((r) => setAgents(r.agents)) .catch(() => {}); }, [open, projectId]); const handleGeneratePrompt = useCallback(async () => { const description = prompt.trim(); if (!description || generating) return; setGenerating(true); try { const { prompt: generated } = await api.battles.generatePrompt(description); setPrompt(generated); promptRef.current?.focus(); } catch (err) { toast.error(err instanceof Error ? err.message : 'Generate failed'); } finally { setGenerating(false); } }, [prompt, generating]); function updateContestant(key: string, patch: Partial) { setContestants((prev) => prev.map((c) => (c.key === key ? { ...c, ...patch } : c))); } function removeContestant(key: string) { setContestants((prev) => prev.filter((c) => c.key !== key)); } function addContestant() { if (contestants.length >= 6) return; setContestants((prev) => [...prev, newContestant()]); } const canStart = !starting && prompt.trim().length > 0 && contestants.length >= 2 && contestants.every((c) => c.identity !== '' && c.model !== '') && !hasDuplicatePair(contestants); const localLaneCount = localCount(battleType, contestants, snapshot); const showLocalWarning = localLaneCount >= 3; async function handleStart() { if (!canStart) return; setStarting(true); try { const { battle_id } = await api.battles.create({ project_id: projectId, battle_type: battleType, prompt: prompt.trim(), contestants: contestants.map((c) => ({ identity: c.identity, model: c.model })), }); sessionEvents.emit({ type: 'open_arena_pane', state: { battle_id, battle_type: battleType, prompt: prompt.trim() }, placement, }); setOpen(false); } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to start battle'); } finally { setStarting(false); } } return (
New Arena Battle

Run the same prompt against multiple AI competitors and pick the best result.

{/* Battle type */}
{(['coding', 'qa'] as const).map((t) => ( ))}

{battleType === 'coding' ? 'Each contestant works in its own isolated worktree. Results include a diff.' : 'Contestants answer the prompt as text. No code changes.'}

{/* Prompt */}