"use client"; import * as React from "react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { AlertTriangle, Columns3, Eye, EyeOff, FileJson, FileText, KeyRound, LayoutGrid, Menu, MoreHorizontal, Plus, Quote, Share2, Square, Swords, Timer, Trophy } from "lucide-react"; import { useApp } from "@/components/app/store"; import { api, streamEvents, ClientApiError, useApi } from "@/lib/client/api"; import type { PolyModel } from "@/lib/client/types"; import { Composer, type PendingAttachment } from "@/components/chat/composer"; import { ModelConfig, type ChatSettings } from "@/components/chat/model-config"; import { Button } from "@/components/ui/button"; import { Kbd } from "@/components/ui/misc"; import { Segmented } from "@/components/ui/segmented"; import { Switch } from "@/components/ui/switch"; import { Tooltip } from "@/components/ui/tooltip"; import { toast } from "@/components/ui/toast"; import { ActionSheet } from "@/components/ui/sheet"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { PROVIDERS } from "@/lib/client/providers"; import { useIsMobile, useSnapCarousel } from "@/lib/client/hooks"; import { estimateAttachmentTokens, estimateTextTokens } from "@/lib/client/tokens"; import { analyzePrompt } from "@/lib/client/router"; import { categoryFromTask, computeWinner, type TaskCategory } from "@/lib/arena/scoring"; import { liveMetrics } from "@/lib/arena/metrics"; import { cn } from "@/lib/utils"; import { ArenaModelPicker } from "./model-picker"; import { ArenaColumn } from "./arena-column"; import { ArenaHistory } from "./arena-history"; import { ModelTabs } from "./model-tabs"; import { WinnerCard } from "./winner-card"; import { ComparisonTable } from "./comparison-table"; import { ArenaShareSheet } from "./share-sheet"; import { useCustomCriteria } from "./vote-panel"; import { useTick } from "./metrics-strip"; import { useFlip } from "./blind"; import { downloadArenaExport } from "./download"; import { blindLabel } from "@/lib/arena/scoring"; import { buildComposerModel, buildSharedModel, columnFromResponse, computeWinners, emptyColumn, isFinal, sessionAttachmentCount, sessionBlind, sessionOrder, type ArenaResponseDto, type ArenaSessionDto, type ArenaVoteDto, type ColumnState } from "./types"; type Layout = "grid" | "columns"; interface ActiveSession { id: string; prompt: string; systemPrompt: string | null; attachmentCount: number; readOnly: boolean; createdAt: string; blind: boolean; /** Display permutation (Blind Arena shuffles positions). */ order: number[]; /** Prompt + system prompt + attachments token estimate (live cost). */ inputTokens: number; category: TaskCategory; } type ArenaEvent = | { type: "meta"; responseId: string; modelKey: string } | { type: "text-delta"; text: string } | { type: "reasoning-delta"; text: string } | { type: "citation"; citation: { url?: string; title?: string; snippet?: string } } | { type: "server-tool"; name: string; status: "started" | "completed" } | { type: "error"; error: { code: string; message: string } } | { type: "done"; response: ArenaResponseDto; status: "complete" | "stopped" | "error" }; function cleanSettings(s: ChatSettings): ChatSettings | undefined { const out = Object.fromEntries(Object.entries(s).filter(([, v]) => v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0))) as ChatSettings; return Object.keys(out).length ? out : undefined; } function gridClass(n: number, layout: Layout): string { if (n <= 1) return "grid-cols-1"; if (n === 2) return "grid-cols-1 md:grid-cols-2"; if (layout === "columns") return cn("grid-cols-1 md:grid-cols-2", n === 3 ? "xl:grid-cols-3" : "xl:grid-cols-4"); return "grid-cols-1 md:grid-cols-2"; } export function ArenaView() { const { modelsByKey, connectedProviders, loadingModels, preferences, setSidebarOpen } = useApp(); const history = useApi<{ sessions: ArenaSessionDto[] }>("/api/arena"); const isMobile = useIsMobile(); const router = useRouter(); const searchParams = useSearchParams(); const criteria = useCustomCriteria(); const [selected, setSelected] = React.useState([]); const [draft, setDraft] = React.useState(""); const [systemPrompt, setSystemPrompt] = React.useState(preferences.defaultSystemPrompt ?? ""); const [settings, setSettings] = React.useState({}); const [attachments, setAttachments] = React.useState([]); const [columns, setColumns] = React.useState([]); const [session, setSession] = React.useState(null); const [votes, setVotes] = React.useState([]); const [running, setRunning] = React.useState(false); const [missingKeys, setMissingKeys] = React.useState(null); const [syncScroll, setSyncScroll] = React.useState(false); const [layout, setLayout] = React.useState("grid"); const [blindNext, setBlindNext] = React.useState(false); const [revealed, setRevealed] = React.useState(true); const [flipping, flip] = useFlip(160); const [voteBusy, setVoteBusy] = React.useState(false); const [menuOpen, setMenuOpen] = React.useState(false); const [shareFor, setShareFor] = React.useState(null); // Single source of truth for column data while streaming: a mutable map, flushed to state at ~25fps. const mapRef = React.useRef>(new Map()); const dirtyRef = React.useRef>(new Set()); const flushTimer = React.useRef | null>(null); const abortRef = React.useRef(null); const keepDraft = React.useRef(null); const flushNow = React.useCallback(() => { if (flushTimer.current) { clearTimeout(flushTimer.current); flushTimer.current = null; } const map = mapRef.current; for (const k of dirtyRef.current) { const c = map.get(k); if (c) map.set(k, { ...c, citations: [...c.citations], serverTools: [...c.serverTools] }); } dirtyRef.current.clear(); setColumns([...map.values()]); }, []); const schedule = React.useCallback(() => { if (!flushTimer.current) flushTimer.current = setTimeout(flushNow, 40); }, [flushNow]); const replaceColumns = React.useCallback( (cols: ColumnState[]) => { mapRef.current = new Map(cols.map((c) => [c.modelKey, c])); dirtyRef.current.clear(); flushNow(); }, [flushNow], ); React.useEffect( () => () => { if (flushTimer.current) clearTimeout(flushTimer.current); abortRef.current?.abort(); }, [], ); // ----- derived -------------------------------------------------------------------------------- const selectedModels = React.useMemo(() => selected.map((k) => modelsByKey.get(k)).filter((m): m is PolyModel => Boolean(m)), [selected, modelsByKey]); const sharedModel = React.useMemo(() => buildSharedModel(selectedModels), [selectedModels]); const composerModel = React.useMemo(() => buildComposerModel(selectedModels, selected.length), [selectedModels, selected.length]); const noVision = React.useMemo(() => selectedModels.filter((m) => !m.capabilities.vision), [selectedModels]); const hasImages = attachments.some((a) => a.kind === "image"); const disconnected = React.useMemo(() => selectedModels.filter((m) => !connectedProviders.has(m.provider)), [selectedModels, connectedProviders]); const winners = React.useMemo(() => computeWinners(columns), [columns]); const allFinal = columns.length > 0 && columns.every((c) => isFinal(c.status)); const anyLive = columns.some((c) => !isFinal(c.status) && c.status !== "idle"); const now = useTick(anyLive || running, 500); const ordered = React.useMemo(() => { if (!session?.blind || session.order.length !== columns.length) return columns; return session.order.map((i) => columns[i]).filter(Boolean); }, [columns, session]); const hidden = Boolean(session?.blind) && !revealed; const { ref: carouselRef, index: carouselIndex, scrollTo: scrollCarousel } = useSnapCarousel(ordered.length); const wonBy = React.useMemo(() => { const m = new Map>(); for (const v of votes) { const s = m.get(v.responseId) ?? new Set(); s.add(v.criterion); m.set(v.responseId, s); } return m; }, [votes]); const EMPTY = React.useMemo(() => new Set(), []); const winner = React.useMemo(() => (allFinal && votes.length ? computeWinner(columns.map((c) => c.response).filter((r): r is ArenaResponseDto => Boolean(r)), votes) : null), [allFinal, votes, columns]); const winnerModel = winner ? modelsByKey.get(winner.modelKey) : undefined; const winnerIndex = winner ? ordered.findIndex((c) => c.responseId === winner.responseId) : -1; const nameOf = React.useCallback((c: ColumnState, i: number) => (hidden ? blindLabel(i) : modelsByKey.get(c.modelKey)?.displayName ?? c.modelKey.split("/").slice(1).join("/")), [hidden, modelsByKey]); const providerOf = React.useCallback((c: ColumnState) => modelsByKey.get(c.modelKey)?.provider ?? c.response?.provider ?? c.modelKey.split("/")[0], [modelsByKey]); const runReason = loadingModels ? "Loading models…" : connectedProviders.size === 0 ? "Connect a provider first" : selected.length === 0 ? "Add at least one model" : disconnected.length ? `${disconnected.map((m) => PROVIDERS[m.provider].shortName).join(", ")} not connected` : !draft.trim() && attachments.length === 0 ? "Type a prompt" : null; const stop = React.useCallback(() => abortRef.current?.abort(), []); React.useEffect(() => { if (!running) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") stop(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [running, stop]); // ----- open a stored session (history / deep link) --------------------------------------------- const openSession = React.useCallback( (s: ArenaSessionDto) => { if (running) return; const cols = s.modelKeys.map((k) => { const r = s.responses.find((x) => x.modelKey === k); return r ? columnFromResponse(r) : { ...emptyColumn(k), status: "stopped" as const }; }); replaceColumns(cols); setVotes(s.votes ?? []); setSession({ id: s.id, prompt: s.prompt, systemPrompt: s.systemPrompt, attachmentCount: sessionAttachmentCount(s), readOnly: true, createdAt: s.createdAt, blind: sessionBlind(s), order: sessionOrder(s), inputTokens: estimateTextTokens(s.prompt) + estimateTextTokens(s.systemPrompt ?? ""), category: categoryFromTask(analyzePrompt(s.prompt).task) }); setRevealed(true); setMissingKeys(null); scrollCarousel(0, false); document.querySelector("[data-arena-main]")?.scrollTo({ top: 0 }); }, [running, replaceColumns, scrollCarousel], ); // ----- deep links: ?models=a,b · ?session= (one-shot, guarded by `appliedLink`) ----------- const appliedLink = React.useRef(null); React.useEffect(() => { if (loadingModels || !modelsByKey.size) return; const key = searchParams.toString(); if (appliedLink.current === key) return; appliedLink.current = key; const models = searchParams.get("models"); if (models) { const keys = [...new Set(models.split(",").map((k) => k.trim()).filter((k) => modelsByKey.has(k)))].slice(0, 4); // eslint-disable-next-line react-hooks/set-state-in-effect -- one-shot URL → state if (keys.length) setSelected(keys); const missing = models.split(",").filter((k) => k.trim() && !modelsByKey.has(k.trim())); if (missing.length) toast.info("Some models were skipped", `${missing.length} model${missing.length === 1 ? " is" : "s are"} not available on your account.`); } const sessionId = searchParams.get("session"); if (sessionId) { api<{ session: ArenaSessionDto }>(`/api/arena/${encodeURIComponent(sessionId)}`) .then(({ session: s }) => openSession(s)) .catch(() => toast.error("Session not found", "This Arena session does not exist or was deleted.")); } if (models || sessionId) router.replace("/app/arena", { scroll: false }); }, [searchParams, modelsByKey, loadingModels, router, openSession]); // ----- streaming ------------------------------------------------------------------------------ const streamOne = React.useCallback( async (sessionId: string, modelKey: string, signal: AbortSignal) => { const map = mapRef.current; const col = () => map.get(modelKey); const touch = () => { dirtyRef.current.add(modelKey); schedule(); }; const c0 = col(); if (c0) c0.startedAt = Date.now(); try { await streamEvents( "/api/arena/stream", { sessionId, modelKey }, (raw) => { const c = col(); if (!c) return; const ev = raw as unknown as ArenaEvent; switch (ev.type) { case "meta": c.responseId = ev.responseId; break; case "text-delta": if (!c.firstTokenAt) c.firstTokenAt = Date.now(); c.text += ev.text; c.status = "streaming"; break; case "reasoning-delta": if (!c.firstTokenAt) c.firstTokenAt = Date.now(); c.reasoning += ev.text; if (c.status === "waiting") c.status = "thinking"; break; case "citation": c.citations.push(ev.citation); break; case "server-tool": c.serverTools.push({ name: ev.name, status: ev.status }); break; case "error": c.error = { code: ev.error.code, message: ev.error.message }; break; case "done": c.response = ev.response; c.responseId = ev.response.id; c.status = ev.status === "complete" ? "done" : ev.status === "stopped" ? "stopped" : "error"; if (ev.response.error && !c.error) c.error = ev.response.error; break; } touch(); }, signal, ); const c = col(); if (c && !isFinal(c.status)) c.status = c.error ? "error" : "stopped"; } catch (e) { const c = col(); if (!c) return; if ((e as Error).name === "AbortError") c.status = "stopped"; else { const err = e as ClientApiError; c.status = "error"; c.error = { code: err.code ?? "HTTP_ERROR", message: err.message }; if (err.code === "NO_PROVIDER_KEY") setMissingKeys((prev) => [...new Set([...(prev ?? []), c.modelKey.split("/")[0]])]); } } dirtyRef.current.add(modelKey); flushNow(); }, [schedule, flushNow], ); const run = React.useCallback( async (prompt: string) => { if (running) return; const text = prompt.trim(); const keys = [...selected]; if (!keys.length) return toast.warning("Add at least one model", "Pick up to four models to compare."); if (!text && attachments.length === 0) return; if (disconnected.length) return toast.warning("Provider not connected", `Add a key for ${disconnected.map((m) => PROVIDERS[m.provider].shortName).join(", ")} in Settings → Providers.`); setRunning(true); setMissingKeys(null); setVotes([]); const attIds = attachments.map((a) => a.id); const inputTokens = estimateTextTokens(text) + estimateTextTokens(systemPrompt) + attachments.reduce((n, a) => n + estimateAttachmentTokens(a), 0); const controller = new AbortController(); abortRef.current = controller; replaceColumns(keys.map((k) => ({ ...emptyColumn(k), status: "waiting" }))); setSession(null); setRevealed(!blindNext); scrollCarousel(0, false); let created: ArenaSessionDto | null = null; try { const res = await api<{ session: ArenaSessionDto }>("/api/arena", { method: "POST", json: { prompt: text || "(see attachments)", systemPrompt: systemPrompt.trim() || undefined, modelKeys: keys, settings: cleanSettings(settings), attachmentIds: attIds.length ? attIds : undefined, blind: blindNext || undefined }, }); created = res.session; setAttachments([]); setSession({ id: created.id, prompt: created.prompt, systemPrompt: created.systemPrompt, attachmentCount: attIds.length, readOnly: false, createdAt: created.createdAt, blind: sessionBlind(created), order: sessionOrder(created), inputTokens, category: categoryFromTask(analyzePrompt(text, attachments).task) }); await Promise.allSettled(keys.map((k) => streamOne(created!.id, k, controller.signal))); // Stopped streams may have missed their `done` frame — pull the persisted rows for metrics. if (controller.signal.aborted) { await new Promise((r) => setTimeout(r, 350)); const data = await history.mutate(); const s = data?.sessions.find((x) => x.id === created!.id); if (s) { for (const r of s.responses) { const c = mapRef.current.get(r.modelKey); if (c && !c.response) { c.response = r; c.responseId = r.id; if (!c.text && r.content) c.text = r.content; dirtyRef.current.add(r.modelKey); } } flushNow(); } } else { void history.mutate(); } } catch (e) { const err = e as ClientApiError; if (err.code === "NO_PROVIDER_KEY") { const providers = ((err.details as { providers?: string[] } | undefined)?.providers ?? []).filter(Boolean); setMissingKeys(providers.length ? providers : ["unknown"]); toast.error("Missing API key", `${providers.map((p) => PROVIDERS[p as keyof typeof PROVIDERS]?.name ?? p).join(", ") || "A provider"} has no key. Add it in Settings → Providers.`); } else { toast.error("Could not start the Arena", err.message); } replaceColumns([]); } finally { setRunning(false); abortRef.current = null; } }, [running, selected, attachments, disconnected, systemPrompt, settings, blindNext, replaceColumns, streamOne, history, flushNow, scrollCarousel], ); // ----- blind reveal ---------------------------------------------------------------------------- const reveal = React.useCallback(() => { if (revealed) return; flip(() => setRevealed(true)); }, [revealed, flip]); // ----- votes ----------------------------------------------------------------------------------- const vote = React.useCallback( async (responseId: string, criterionId: string, on: boolean) => { if (!session) return; setVoteBusy(true); try { const res = on ? await api<{ votes: ArenaVoteDto[]; ratings: Record> }>("/api/arena/vote", { method: "POST", json: { sessionId: session.id, responseId, criterion: criterionId, category: session.category } }) : await api<{ votes: ArenaVoteDto[]; ratings: Record> }>("/api/arena/vote", { method: "DELETE", json: { sessionId: session.id, criterion: criterionId } }); setVotes(res.votes); for (const c of mapRef.current.values()) { if (c.response && res.ratings[c.response.id]) { c.response = { ...c.response, ratings: res.ratings[c.response.id] }; dirtyRef.current.add(c.modelKey); } } flushNow(); if (on && criterionId === "best") reveal(); void history.mutate(); } catch (e) { toast.error("Could not save your vote", (e as Error).message); } finally { setVoteBusy(false); } }, [session, flushNow, history, reveal], ); // ----- history --------------------------------------------------------------------------------- const rerunSession = React.useCallback( (s: ArenaSessionDto) => { if (running) return; const known = s.modelKeys.filter((k) => modelsByKey.has(k)); const usable = known.filter((k) => connectedProviders.has(modelsByKey.get(k)!.provider)); if (usable.length < s.modelKeys.length) toast.warning("Some models were skipped", "They are no longer available or their provider is disconnected."); setSelected(usable.slice(0, 4)); setDraft(s.prompt); setSystemPrompt(s.systemPrompt ?? ""); const { attachmentIds: _a, blind: _b, blindOrder: _o, ...rest } = (s.settings ?? {}) as ChatSettings & { attachmentIds?: string[]; blind?: boolean; blindOrder?: number[] }; void _a; void _o; setBlindNext(Boolean(_b)); setSettings(rest); replaceColumns([]); setSession(null); setVotes([]); setMissingKeys(null); requestAnimationFrame(() => document.querySelector('textarea[aria-label="Message"]')?.focus()); }, [running, modelsByKey, connectedProviders, replaceColumns], ); const reset = React.useCallback(() => { if (running) return; replaceColumns([]); setSession(null); setVotes([]); setMissingKeys(null); setRevealed(true); }, [running, replaceColumns]); const deleteSession = React.useCallback( async (s: ArenaSessionDto) => { try { await api(`/api/arena/${encodeURIComponent(s.id)}`, { method: "DELETE" }); if (session?.id === s.id) reset(); await history.mutate(); toast.success("Comparison deleted"); } catch (e) { toast.error("Could not delete", (e as Error).message); } }, [session, reset, history], ); const exportSession = React.useCallback(async (id: string, format: "markdown" | "json") => { try { const name = await downloadArenaExport(id, format); toast.success("Export ready", name); } catch (e) { toast.error("Export failed", (e as Error).message); } }, []); // ----- sync scroll ----------------------------------------------------------------------------- const bodies = React.useRef(new Map()); const syncing = React.useRef(false); const onBodyScroll = React.useCallback( (source: HTMLDivElement) => { if (!syncScroll || syncing.current) return; const max = source.scrollHeight - source.clientHeight; const ratio = max > 0 ? source.scrollTop / max : 0; syncing.current = true; for (const el of bodies.current.values()) { if (el === source) continue; el.scrollTop = ratio * (el.scrollHeight - el.clientHeight); } requestAnimationFrame(() => { syncing.current = false; }); }, [syncScroll], ); const noProviders = !loadingModels && connectedProviders.size === 0; const canAct = Boolean(session) && !running; const menuItems = session ? [ { key: "md", label: "Export Markdown", icon: , onSelect: () => exportSession(session.id, "markdown"), disabled: !canAct }, { key: "json", label: "Export JSON", icon: , onSelect: () => exportSession(session.id, "json"), disabled: !canAct }, { key: "share", label: "Share…", icon: , onSelect: () => setShareFor(session.id), disabled: !canAct }, "separator" as const, { key: "board", label: "Scoreboard", icon: , onSelect: () => router.push("/app/arena/scoreboard") }, ] : [{ key: "board", label: "Scoreboard", icon: , onSelect: () => router.push("/app/arena/scoreboard") }]; const tabItems = ordered.map((c, i) => { const m = modelsByKey.get(c.modelKey); const lm = liveMetrics(c, m, session?.inputTokens ?? 0, now); return { key: c.modelKey, name: nameOf(c, i), provider: providerOf(c), status: c.status, tokensPerSecond: lm.tokensPerSecond, isWinner: winner?.responseId === c.responseId, hidden, blindIndex: i }; }); const comparisonRows = allFinal ? ordered.map((c, i) => ({ key: c.modelKey, name: nameOf(c, i), provider: providerOf(c), hidden, blindIndex: i, metrics: liveMetrics(c, modelsByKey.get(c.modelKey), session?.inputTokens ?? 0, now), criteriaWon: c.responseId ? [...(wonBy.get(c.responseId) ?? [])] : [], isWinner: winner?.responseId === c.responseId })) : []; const renderColumn = (c: ColumnState, i: number, extra?: { className?: string; style?: React.CSSProperties; sync?: boolean }) => (