TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import Link from "next/link";4import { useRouter, useSearchParams } from "next/navigation";5import { AlertTriangle, Columns3, Eye, EyeOff, FileJson, FileText, KeyRound, LayoutGrid, Menu, MoreHorizontal, Plus, Quote, Share2, Square, Swords, Timer, Trophy } from "lucide-react";6import { useApp } from "@/components/app/store";7import { api, streamEvents, ClientApiError, useApi } from "@/lib/client/api";8import type { PolyModel } from "@/lib/client/types";9import { Composer, type PendingAttachment } from "@/components/chat/composer";10import { ModelConfig, type ChatSettings } from "@/components/chat/model-config";11import { Button } from "@/components/ui/button";12import { Kbd } from "@/components/ui/misc";13import { Segmented } from "@/components/ui/segmented";14import { Switch } from "@/components/ui/switch";15import { Tooltip } from "@/components/ui/tooltip";16import { toast } from "@/components/ui/toast";17import { ActionSheet } from "@/components/ui/sheet";18import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";19import { PROVIDERS } from "@/lib/client/providers";20import { useIsMobile, useSnapCarousel } from "@/lib/client/hooks";21import { estimateAttachmentTokens, estimateTextTokens } from "@/lib/client/tokens";22import { analyzePrompt } from "@/lib/client/router";23import { categoryFromTask, computeWinner, type TaskCategory } from "@/lib/arena/scoring";24import { liveMetrics } from "@/lib/arena/metrics";25import { cn } from "@/lib/utils";26import { ArenaModelPicker } from "./model-picker";27import { ArenaColumn } from "./arena-column";28import { ArenaHistory } from "./arena-history";29import { ModelTabs } from "./model-tabs";30import { WinnerCard } from "./winner-card";31import { ComparisonTable } from "./comparison-table";32import { ArenaShareSheet } from "./share-sheet";33import { useCustomCriteria } from "./vote-panel";34import { useTick } from "./metrics-strip";35import { useFlip } from "./blind";36import { downloadArenaExport } from "./download";37import { blindLabel } from "@/lib/arena/scoring";38import { buildComposerModel, buildSharedModel, columnFromResponse, computeWinners, emptyColumn, isFinal, sessionAttachmentCount, sessionBlind, sessionOrder, type ArenaResponseDto, type ArenaSessionDto, type ArenaVoteDto, type ColumnState } from "./types";3940type Layout = "grid" | "columns";4142interface ActiveSession {43 id: string;44 prompt: string;45 systemPrompt: string | null;46 attachmentCount: number;47 readOnly: boolean;48 createdAt: string;49 blind: boolean;50 /** Display permutation (Blind Arena shuffles positions). */51 order: number[];52 /** Prompt + system prompt + attachments token estimate (live cost). */53 inputTokens: number;54 category: TaskCategory;55}5657type ArenaEvent =58 | { type: "meta"; responseId: string; modelKey: string }59 | { type: "text-delta"; text: string }60 | { type: "reasoning-delta"; text: string }61 | { type: "citation"; citation: { url?: string; title?: string; snippet?: string } }62 | { type: "server-tool"; name: string; status: "started" | "completed" }63 | { type: "error"; error: { code: string; message: string } }64 | { type: "done"; response: ArenaResponseDto; status: "complete" | "stopped" | "error" };6566function cleanSettings(s: ChatSettings): ChatSettings | undefined {67 const out = Object.fromEntries(Object.entries(s).filter(([, v]) => v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0))) as ChatSettings;68 return Object.keys(out).length ? out : undefined;69}7071function gridClass(n: number, layout: Layout): string {72 if (n <= 1) return "grid-cols-1";73 if (n === 2) return "grid-cols-1 md:grid-cols-2";74 if (layout === "columns") return cn("grid-cols-1 md:grid-cols-2", n === 3 ? "xl:grid-cols-3" : "xl:grid-cols-4");75 return "grid-cols-1 md:grid-cols-2";76}7778export function ArenaView() {79 const { modelsByKey, connectedProviders, loadingModels, preferences, setSidebarOpen } = useApp();80 const history = useApi<{ sessions: ArenaSessionDto[] }>("/api/arena");81 const isMobile = useIsMobile();82 const router = useRouter();83 const searchParams = useSearchParams();84 const criteria = useCustomCriteria();8586 const [selected, setSelected] = React.useState<string[]>([]);87 const [draft, setDraft] = React.useState("");88 const [systemPrompt, setSystemPrompt] = React.useState<string>(preferences.defaultSystemPrompt ?? "");89 const [settings, setSettings] = React.useState<ChatSettings>({});90 const [attachments, setAttachments] = React.useState<PendingAttachment[]>([]);91 const [columns, setColumns] = React.useState<ColumnState[]>([]);92 const [session, setSession] = React.useState<ActiveSession | null>(null);93 const [votes, setVotes] = React.useState<ArenaVoteDto[]>([]);94 const [running, setRunning] = React.useState(false);95 const [missingKeys, setMissingKeys] = React.useState<string[] | null>(null);96 const [syncScroll, setSyncScroll] = React.useState(false);97 const [layout, setLayout] = React.useState<Layout>("grid");98 const [blindNext, setBlindNext] = React.useState(false);99 const [revealed, setRevealed] = React.useState(true);100 const [flipping, flip] = useFlip(160);101 const [voteBusy, setVoteBusy] = React.useState(false);102 const [menuOpen, setMenuOpen] = React.useState(false);103 const [shareFor, setShareFor] = React.useState<string | null>(null);104105 // Single source of truth for column data while streaming: a mutable map, flushed to state at ~25fps.106 const mapRef = React.useRef<Map<string, ColumnState>>(new Map());107 const dirtyRef = React.useRef<Set<string>>(new Set());108 const flushTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);109 const abortRef = React.useRef<AbortController | null>(null);110 const keepDraft = React.useRef<string | null>(null);111112 const flushNow = React.useCallback(() => {113 if (flushTimer.current) {114 clearTimeout(flushTimer.current);115 flushTimer.current = null;116 }117 const map = mapRef.current;118 for (const k of dirtyRef.current) {119 const c = map.get(k);120 if (c) map.set(k, { ...c, citations: [...c.citations], serverTools: [...c.serverTools] });121 }122 dirtyRef.current.clear();123 setColumns([...map.values()]);124 }, []);125 const schedule = React.useCallback(() => {126 if (!flushTimer.current) flushTimer.current = setTimeout(flushNow, 40);127 }, [flushNow]);128 const replaceColumns = React.useCallback(129 (cols: ColumnState[]) => {130 mapRef.current = new Map(cols.map((c) => [c.modelKey, c]));131 dirtyRef.current.clear();132 flushNow();133 },134 [flushNow],135 );136 React.useEffect(137 () => () => {138 if (flushTimer.current) clearTimeout(flushTimer.current);139 abortRef.current?.abort();140 },141 [],142 );143144 // ----- derived --------------------------------------------------------------------------------145 const selectedModels = React.useMemo(() => selected.map((k) => modelsByKey.get(k)).filter((m): m is PolyModel => Boolean(m)), [selected, modelsByKey]);146 const sharedModel = React.useMemo(() => buildSharedModel(selectedModels), [selectedModels]);147 const composerModel = React.useMemo(() => buildComposerModel(selectedModels, selected.length), [selectedModels, selected.length]);148 const noVision = React.useMemo(() => selectedModels.filter((m) => !m.capabilities.vision), [selectedModels]);149 const hasImages = attachments.some((a) => a.kind === "image");150 const disconnected = React.useMemo(() => selectedModels.filter((m) => !connectedProviders.has(m.provider)), [selectedModels, connectedProviders]);151 const winners = React.useMemo(() => computeWinners(columns), [columns]);152 const allFinal = columns.length > 0 && columns.every((c) => isFinal(c.status));153 const anyLive = columns.some((c) => !isFinal(c.status) && c.status !== "idle");154 const now = useTick(anyLive || running, 500);155156 const ordered = React.useMemo(() => {157 if (!session?.blind || session.order.length !== columns.length) return columns;158 return session.order.map((i) => columns[i]).filter(Boolean);159 }, [columns, session]);160 const hidden = Boolean(session?.blind) && !revealed;161 const { ref: carouselRef, index: carouselIndex, scrollTo: scrollCarousel } = useSnapCarousel<HTMLDivElement>(ordered.length);162163 const wonBy = React.useMemo(() => {164 const m = new Map<string, Set<string>>();165 for (const v of votes) {166 const s = m.get(v.responseId) ?? new Set<string>();167 s.add(v.criterion);168 m.set(v.responseId, s);169 }170 return m;171 }, [votes]);172 const EMPTY = React.useMemo(() => new Set<string>(), []);173 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]);174 const winnerModel = winner ? modelsByKey.get(winner.modelKey) : undefined;175 const winnerIndex = winner ? ordered.findIndex((c) => c.responseId === winner.responseId) : -1;176177 const nameOf = React.useCallback((c: ColumnState, i: number) => (hidden ? blindLabel(i) : modelsByKey.get(c.modelKey)?.displayName ?? c.modelKey.split("/").slice(1).join("/")), [hidden, modelsByKey]);178 const providerOf = React.useCallback((c: ColumnState) => modelsByKey.get(c.modelKey)?.provider ?? c.response?.provider ?? c.modelKey.split("/")[0], [modelsByKey]);179180 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;181182 const stop = React.useCallback(() => abortRef.current?.abort(), []);183184 React.useEffect(() => {185 if (!running) return;186 const onKey = (e: KeyboardEvent) => {187 if (e.key === "Escape") stop();188 };189 window.addEventListener("keydown", onKey);190 return () => window.removeEventListener("keydown", onKey);191 }, [running, stop]);192193 // ----- open a stored session (history / deep link) ---------------------------------------------194 const openSession = React.useCallback(195 (s: ArenaSessionDto) => {196 if (running) return;197 const cols = s.modelKeys.map((k) => {198 const r = s.responses.find((x) => x.modelKey === k);199 return r ? columnFromResponse(r) : { ...emptyColumn(k), status: "stopped" as const };200 });201 replaceColumns(cols);202 setVotes(s.votes ?? []);203 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) });204 setRevealed(true);205 setMissingKeys(null);206 scrollCarousel(0, false);207 document.querySelector<HTMLElement>("[data-arena-main]")?.scrollTo({ top: 0 });208 },209 [running, replaceColumns, scrollCarousel],210 );211212 // ----- deep links: ?models=a,b · ?session=<id> (one-shot, guarded by `appliedLink`) -----------213 const appliedLink = React.useRef<string | null>(null);214 React.useEffect(() => {215 if (loadingModels || !modelsByKey.size) return;216 const key = searchParams.toString();217 if (appliedLink.current === key) return;218 appliedLink.current = key;219 const models = searchParams.get("models");220 if (models) {221 const keys = [...new Set(models.split(",").map((k) => k.trim()).filter((k) => modelsByKey.has(k)))].slice(0, 4);222 // eslint-disable-next-line react-hooks/set-state-in-effect -- one-shot URL → state223 if (keys.length) setSelected(keys);224 const missing = models.split(",").filter((k) => k.trim() && !modelsByKey.has(k.trim()));225 if (missing.length) toast.info("Some models were skipped", `${missing.length} model${missing.length === 1 ? " is" : "s are"} not available on your account.`);226 }227 const sessionId = searchParams.get("session");228 if (sessionId) {229 api<{ session: ArenaSessionDto }>(`/api/arena/${encodeURIComponent(sessionId)}`)230 .then(({ session: s }) => openSession(s))231 .catch(() => toast.error("Session not found", "This Arena session does not exist or was deleted."));232 }233 if (models || sessionId) router.replace("/app/arena", { scroll: false });234 }, [searchParams, modelsByKey, loadingModels, router, openSession]);235236 // ----- streaming ------------------------------------------------------------------------------237 const streamOne = React.useCallback(238 async (sessionId: string, modelKey: string, signal: AbortSignal) => {239 const map = mapRef.current;240 const col = () => map.get(modelKey);241 const touch = () => {242 dirtyRef.current.add(modelKey);243 schedule();244 };245 const c0 = col();246 if (c0) c0.startedAt = Date.now();247 try {248 await streamEvents(249 "/api/arena/stream",250 { sessionId, modelKey },251 (raw) => {252 const c = col();253 if (!c) return;254 const ev = raw as unknown as ArenaEvent;255 switch (ev.type) {256 case "meta":257 c.responseId = ev.responseId;258 break;259 case "text-delta":260 if (!c.firstTokenAt) c.firstTokenAt = Date.now();261 c.text += ev.text;262 c.status = "streaming";263 break;264 case "reasoning-delta":265 if (!c.firstTokenAt) c.firstTokenAt = Date.now();266 c.reasoning += ev.text;267 if (c.status === "waiting") c.status = "thinking";268 break;269 case "citation":270 c.citations.push(ev.citation);271 break;272 case "server-tool":273 c.serverTools.push({ name: ev.name, status: ev.status });274 break;275 case "error":276 c.error = { code: ev.error.code, message: ev.error.message };277 break;278 case "done":279 c.response = ev.response;280 c.responseId = ev.response.id;281 c.status = ev.status === "complete" ? "done" : ev.status === "stopped" ? "stopped" : "error";282 if (ev.response.error && !c.error) c.error = ev.response.error;283 break;284 }285 touch();286 },287 signal,288 );289 const c = col();290 if (c && !isFinal(c.status)) c.status = c.error ? "error" : "stopped";291 } catch (e) {292 const c = col();293 if (!c) return;294 if ((e as Error).name === "AbortError") c.status = "stopped";295 else {296 const err = e as ClientApiError;297 c.status = "error";298 c.error = { code: err.code ?? "HTTP_ERROR", message: err.message };299 if (err.code === "NO_PROVIDER_KEY") setMissingKeys((prev) => [...new Set([...(prev ?? []), c.modelKey.split("/")[0]])]);300 }301 }302 dirtyRef.current.add(modelKey);303 flushNow();304 },305 [schedule, flushNow],306 );307308 const run = React.useCallback(309 async (prompt: string) => {310 if (running) return;311 const text = prompt.trim();312 const keys = [...selected];313 if (!keys.length) return toast.warning("Add at least one model", "Pick up to four models to compare.");314 if (!text && attachments.length === 0) return;315 if (disconnected.length) return toast.warning("Provider not connected", `Add a key for ${disconnected.map((m) => PROVIDERS[m.provider].shortName).join(", ")} in Settings → Providers.`);316317 setRunning(true);318 setMissingKeys(null);319 setVotes([]);320 const attIds = attachments.map((a) => a.id);321 const inputTokens = estimateTextTokens(text) + estimateTextTokens(systemPrompt) + attachments.reduce((n, a) => n + estimateAttachmentTokens(a), 0);322 const controller = new AbortController();323 abortRef.current = controller;324 replaceColumns(keys.map((k) => ({ ...emptyColumn(k), status: "waiting" })));325 setSession(null);326 setRevealed(!blindNext);327 scrollCarousel(0, false);328329 let created: ArenaSessionDto | null = null;330 try {331 const res = await api<{ session: ArenaSessionDto }>("/api/arena", {332 method: "POST",333 json: { prompt: text || "(see attachments)", systemPrompt: systemPrompt.trim() || undefined, modelKeys: keys, settings: cleanSettings(settings), attachmentIds: attIds.length ? attIds : undefined, blind: blindNext || undefined },334 });335 created = res.session;336 setAttachments([]);337 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) });338 await Promise.allSettled(keys.map((k) => streamOne(created!.id, k, controller.signal)));339340 // Stopped streams may have missed their `done` frame — pull the persisted rows for metrics.341 if (controller.signal.aborted) {342 await new Promise((r) => setTimeout(r, 350));343 const data = await history.mutate();344 const s = data?.sessions.find((x) => x.id === created!.id);345 if (s) {346 for (const r of s.responses) {347 const c = mapRef.current.get(r.modelKey);348 if (c && !c.response) {349 c.response = r;350 c.responseId = r.id;351 if (!c.text && r.content) c.text = r.content;352 dirtyRef.current.add(r.modelKey);353 }354 }355 flushNow();356 }357 } else {358 void history.mutate();359 }360 } catch (e) {361 const err = e as ClientApiError;362 if (err.code === "NO_PROVIDER_KEY") {363 const providers = ((err.details as { providers?: string[] } | undefined)?.providers ?? []).filter(Boolean);364 setMissingKeys(providers.length ? providers : ["unknown"]);365 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.`);366 } else {367 toast.error("Could not start the Arena", err.message);368 }369 replaceColumns([]);370 } finally {371 setRunning(false);372 abortRef.current = null;373 }374 },375 [running, selected, attachments, disconnected, systemPrompt, settings, blindNext, replaceColumns, streamOne, history, flushNow, scrollCarousel],376 );377378 // ----- blind reveal ----------------------------------------------------------------------------379 const reveal = React.useCallback(() => {380 if (revealed) return;381 flip(() => setRevealed(true));382 }, [revealed, flip]);383384 // ----- votes -----------------------------------------------------------------------------------385 const vote = React.useCallback(386 async (responseId: string, criterionId: string, on: boolean) => {387 if (!session) return;388 setVoteBusy(true);389 try {390 const res = on391 ? await api<{ votes: ArenaVoteDto[]; ratings: Record<string, Record<string, boolean>> }>("/api/arena/vote", { method: "POST", json: { sessionId: session.id, responseId, criterion: criterionId, category: session.category } })392 : await api<{ votes: ArenaVoteDto[]; ratings: Record<string, Record<string, boolean>> }>("/api/arena/vote", { method: "DELETE", json: { sessionId: session.id, criterion: criterionId } });393 setVotes(res.votes);394 for (const c of mapRef.current.values()) {395 if (c.response && res.ratings[c.response.id]) {396 c.response = { ...c.response, ratings: res.ratings[c.response.id] };397 dirtyRef.current.add(c.modelKey);398 }399 }400 flushNow();401 if (on && criterionId === "best") reveal();402 void history.mutate();403 } catch (e) {404 toast.error("Could not save your vote", (e as Error).message);405 } finally {406 setVoteBusy(false);407 }408 },409 [session, flushNow, history, reveal],410 );411412 // ----- history ---------------------------------------------------------------------------------413 const rerunSession = React.useCallback(414 (s: ArenaSessionDto) => {415 if (running) return;416 const known = s.modelKeys.filter((k) => modelsByKey.has(k));417 const usable = known.filter((k) => connectedProviders.has(modelsByKey.get(k)!.provider));418 if (usable.length < s.modelKeys.length) toast.warning("Some models were skipped", "They are no longer available or their provider is disconnected.");419 setSelected(usable.slice(0, 4));420 setDraft(s.prompt);421 setSystemPrompt(s.systemPrompt ?? "");422 const { attachmentIds: _a, blind: _b, blindOrder: _o, ...rest } = (s.settings ?? {}) as ChatSettings & { attachmentIds?: string[]; blind?: boolean; blindOrder?: number[] };423 void _a;424 void _o;425 setBlindNext(Boolean(_b));426 setSettings(rest);427 replaceColumns([]);428 setSession(null);429 setVotes([]);430 setMissingKeys(null);431 requestAnimationFrame(() => document.querySelector<HTMLTextAreaElement>('textarea[aria-label="Message"]')?.focus());432 },433 [running, modelsByKey, connectedProviders, replaceColumns],434 );435436 const reset = React.useCallback(() => {437 if (running) return;438 replaceColumns([]);439 setSession(null);440 setVotes([]);441 setMissingKeys(null);442 setRevealed(true);443 }, [running, replaceColumns]);444445 const deleteSession = React.useCallback(446 async (s: ArenaSessionDto) => {447 try {448 await api(`/api/arena/${encodeURIComponent(s.id)}`, { method: "DELETE" });449 if (session?.id === s.id) reset();450 await history.mutate();451 toast.success("Comparison deleted");452 } catch (e) {453 toast.error("Could not delete", (e as Error).message);454 }455 },456 [session, reset, history],457 );458459 const exportSession = React.useCallback(async (id: string, format: "markdown" | "json") => {460 try {461 const name = await downloadArenaExport(id, format);462 toast.success("Export ready", name);463 } catch (e) {464 toast.error("Export failed", (e as Error).message);465 }466 }, []);467468 // ----- sync scroll -----------------------------------------------------------------------------469 const bodies = React.useRef(new Map<string, HTMLDivElement>());470 const syncing = React.useRef(false);471 const onBodyScroll = React.useCallback(472 (source: HTMLDivElement) => {473 if (!syncScroll || syncing.current) return;474 const max = source.scrollHeight - source.clientHeight;475 const ratio = max > 0 ? source.scrollTop / max : 0;476 syncing.current = true;477 for (const el of bodies.current.values()) {478 if (el === source) continue;479 el.scrollTop = ratio * (el.scrollHeight - el.clientHeight);480 }481 requestAnimationFrame(() => {482 syncing.current = false;483 });484 },485 [syncScroll],486 );487488 const noProviders = !loadingModels && connectedProviders.size === 0;489 const canAct = Boolean(session) && !running;490491 const menuItems = session492 ? [493 { key: "md", label: "Export Markdown", icon: <FileText />, onSelect: () => exportSession(session.id, "markdown"), disabled: !canAct },494 { key: "json", label: "Export JSON", icon: <FileJson />, onSelect: () => exportSession(session.id, "json"), disabled: !canAct },495 { key: "share", label: "Share…", icon: <Share2 />, onSelect: () => setShareFor(session.id), disabled: !canAct },496 "separator" as const,497 { key: "board", label: "Scoreboard", icon: <Trophy />, onSelect: () => router.push("/app/arena/scoreboard") },498 ]499 : [{ key: "board", label: "Scoreboard", icon: <Trophy />, onSelect: () => router.push("/app/arena/scoreboard") }];500501 const tabItems = ordered.map((c, i) => {502 const m = modelsByKey.get(c.modelKey);503 const lm = liveMetrics(c, m, session?.inputTokens ?? 0, now);504 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 };505 });506 const comparisonRows = allFinal507 ? 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 }))508 : [];509510 const renderColumn = (c: ColumnState, i: number, extra?: { className?: string; style?: React.CSSProperties; sync?: boolean }) => (511 <ArenaColumn512 key={c.modelKey}513 column={c}514 model={modelsByKey.get(c.modelKey)}515 index={i}516 hidden={hidden}517 flipping={flipping}518 onReveal={hidden ? reveal : undefined}519 winners={winners}520 isWinner={Boolean(winner) && winner!.responseId === c.responseId}521 criteria={criteria.all}522 won={c.responseId ? wonBy.get(c.responseId) ?? EMPTY : EMPTY}523 onVote={vote}524 onAddCriterion={(label) => {525 const added = criteria.add(label);526 if (!added) toast.warning("Give the criterion a name");527 }}528 onRemoveCriterion={criteria.remove}529 voteBusy={voteBusy}530 inputTokens={session?.inputTokens ?? 0}531 now={now}532 wrapCode={preferences.codeWrap}533 showReasoning={preferences.showReasoning}534 showCosts={preferences.showCosts}535 bodyRef={536 extra?.sync537 ? (el) => {538 if (el) bodies.current.set(c.modelKey, el);539 else bodies.current.delete(c.modelKey);540 }541 : undefined542 }543 onBodyScroll={extra?.sync ? onBodyScroll : undefined}544 className={extra?.className}545 style={extra?.style}546 />547 );548549 return (550 <div className="flex h-full min-h-0 flex-col">551 {/* Header */}552 <header className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-2 sm:px-4">553 <Button variant="ghost" size="icon-sm" className="md:hidden" onClick={() => setSidebarOpen(true)} aria-label="Open sidebar">554 <Menu />555 </Button>556 <span className="flex size-7 items-center justify-center rounded-md bg-accent-soft text-accent">557 <Swords className="size-4" />558 </span>559 <div className="min-w-0 leading-tight">560 <h1 className="text-[14px] font-semibold tracking-tight">Arena</h1>561 <p className="hidden truncate text-[11.5px] text-fg-muted sm:block">One prompt. Up to 4 models. Side by side.</p>562 </div>563 <div className="ml-auto flex items-center gap-1.5 sm:gap-2">564 {ordered.length >= 3 ? (565 <Segmented566 size="sm"567 ariaLabel="Layout"568 className="hidden md:inline-flex"569 value={layout}570 onChange={setLayout}571 options={[572 { value: "grid", label: <span className="sr-only">Grid</span>, icon: <LayoutGrid /> },573 { value: "columns", label: <span className="sr-only">Columns</span>, icon: <Columns3 /> },574 ]}575 />576 ) : null}577 {ordered.length > 1 ? (578 <label className="hidden items-center gap-2 text-[12px] text-fg-muted lg:flex">579 <Switch size="sm" checked={syncScroll} onCheckedChange={setSyncScroll} aria-label="Sync scroll" />580 Sync scroll581 </label>582 ) : null}583 {hidden && allFinal ? (584 <Button variant="outline" size="sm" onClick={reveal}>585 <Eye /> Reveal586 </Button>587 ) : null}588 {running ? (589 <Button variant="secondary" size="sm" onClick={stop}>590 <Square className="size-3.5 fill-current" /> Stop <Kbd className="ml-1 hidden sm:inline-flex">esc</Kbd>591 </Button>592 ) : columns.length ? (593 <Tooltip content="New comparison">594 <Button variant="ghost" size="icon-sm" onClick={reset} aria-label="New comparison">595 <Plus />596 </Button>597 </Tooltip>598 ) : null}599 <Tooltip content="Scoreboard">600 <Button asChild variant="ghost" size="icon-sm" className="hidden sm:inline-flex" aria-label="Scoreboard">601 <Link href="/app/arena/scoreboard">602 <Trophy />603 </Link>604 </Button>605 </Tooltip>606 {isMobile ? (607 <Button variant="ghost" size="icon-sm" onClick={() => setMenuOpen(true)} aria-label="More actions">608 <MoreHorizontal />609 </Button>610 ) : (611 <DropdownMenu>612 <DropdownMenuTrigger asChild>613 <Button variant="ghost" size="icon-sm" aria-label="More actions">614 <MoreHorizontal />615 </Button>616 </DropdownMenuTrigger>617 <DropdownMenuContent align="end">618 {menuItems.map((it, i) =>619 it === "separator" ? (620 <DropdownMenuSeparator key={`sep-${i}`} />621 ) : (622 <DropdownMenuItem key={it.key} disabled={it.disabled} onSelect={it.onSelect}>623 {it.icon}624 {it.label}625 </DropdownMenuItem>626 ),627 )}628 </DropdownMenuContent>629 </DropdownMenu>630 )}631 </div>632 </header>633634 {/* Scrollable results */}635 <main data-arena-main className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">636 <div className="mx-auto w-full max-w-[1680px] space-y-4 px-3 py-3 sm:px-5 sm:py-4 lg:px-6">637 {missingKeys ? (638 <div className="flex flex-wrap items-center gap-2 rounded-lg border border-danger/30 bg-danger-soft px-3 py-2 text-[13px] text-danger">639 <KeyRound className="size-4 shrink-0" />640 <span className="min-w-0 flex-1">No API key for {missingKeys.map((p) => PROVIDERS[p as keyof typeof PROVIDERS]?.name ?? p).join(", ")}.</span>641 <Button asChild size="xs" variant="outline">642 <Link href="/app/settings/providers">Open Providers</Link>643 </Button>644 </div>645 ) : null}646647 {columns.length === 0 ? (648 <ArenaEmpty noProviders={noProviders} />649 ) : (650 <section className="space-y-3" aria-label="Results" aria-busy={running}>651 {session ? (652 <div className="flex flex-wrap items-start gap-2 text-[12.5px] text-fg-muted">653 <Quote className="mt-0.5 size-3.5 shrink-0 text-fg-subtle" />654 <p className="min-w-0 flex-1 line-clamp-2 leading-5" title={session.prompt}>655 {session.prompt}656 </p>657 <span className="flex shrink-0 flex-wrap items-center gap-1.5 text-[11.5px] text-fg-subtle">658 {session.blind ? (659 <span className="inline-flex items-center gap-1 rounded bg-bg-muted px-1.5 py-0.5">660 <EyeOff className="size-3" /> blind661 </span>662 ) : null}663 {session.attachmentCount ? <span>{session.attachmentCount} attachment{session.attachmentCount === 1 ? "" : "s"}</span> : null}664 {session.systemPrompt ? <span title={session.systemPrompt}>system prompt</span> : null}665 {session.readOnly ? <span className="rounded bg-bg-muted px-1.5 py-0.5">stored · {new Date(session.createdAt).toLocaleString([], { dateStyle: "medium", timeStyle: "short" })}</span> : null}666 </span>667 </div>668 ) : null}669670 {isMobile ? (671 <>672 <div className="sticky top-0 z-10 -mx-3 bg-bg/95 px-3 py-1.5 backdrop-blur-sm">673 <ModelTabs items={tabItems} active={carouselIndex} onSelect={(i) => scrollCarousel(i)} />674 </div>675 <div ref={carouselRef} className="snap-row" aria-roledescription="carousel" data-no-edge-swipe>676 {ordered.map((c, i) => (677 <div key={c.modelKey} className="px-0.5">678 {renderColumn(c, i)}679 </div>680 ))}681 </div>682 {ordered.length > 1 ? (683 <p className="text-center text-[11.5px] text-fg-subtle">684 Swipe to compare · {carouselIndex + 1} / {ordered.length}685 </p>686 ) : null}687 </>688 ) : (689 <div className={cn("grid gap-3", gridClass(ordered.length, layout))}>{ordered.map((c, i) => renderColumn(c, i, { className: "animate-fade-up", style: { animationDelay: `${i * 40}ms` }, sync: true }))}</div>690 )}691692 {winner ? <WinnerCard winner={winner} name={hidden ? blindLabel(Math.max(0, winnerIndex)) : winnerModel?.displayName ?? winner.modelKey.split("/").slice(1).join("/")} provider={winnerModel?.provider ?? winner.modelKey.split("/")[0]} hidden={hidden} blindIndex={Math.max(0, winnerIndex)} customCriteria={criteria.custom} showCosts={preferences.showCosts} /> : null}693694 {allFinal && !winner ? (695 <p className="text-[12px] text-fg-subtle">696 All done. Vote on the criteria that matter to you — the model with the most criteria becomes the Arena winner and feeds your <Link href="/app/arena/scoreboard" className="text-accent underline-offset-4 hover:underline">scoreboard</Link>.697 {hidden ? " Voting “Best answer” reveals the models." : ""}698 </p>699 ) : null}700701 {comparisonRows.length > 1 ? <ComparisonTable rows={comparisonRows} showCosts={preferences.showCosts} customCriteria={criteria.custom} /> : null}702 </section>703 )}704705 <ArenaHistory sessions={history.data?.sessions} loading={history.isLoading} activeId={session?.id ?? null} disabled={running} onOpen={openSession} onRerun={rerunSession} onDelete={deleteSession} onExport={(s, f) => exportSession(s.id, f)} onShare={(s) => setShareFor(s.id)} />706 </div>707 </main>708709 {/* Composer, fixed at the bottom (the shell reserves the bottom-nav space) */}710 <div className="shrink-0 border-t border-border bg-bg px-3 pb-2 pt-2 sm:px-5 lg:px-6">711 <div className="mx-auto w-full max-w-[1680px] space-y-2">712 <ArenaModelPicker selected={selected} onChange={setSelected} disabled={running} />713 <Composer714 model={composerModel}715 busy={running}716 enterToSend={preferences.enterToSend}717 attachments={attachments}718 onAttachmentsChange={setAttachments}719 value={draft}720 onValueChange={(v) => {721 if (v === "" && keepDraft.current !== null) {722 // Composer clears its text after send; the Arena keeps the prompt for iteration.723 setDraft(keepDraft.current);724 keepDraft.current = null;725 return;726 }727 setDraft(v);728 }}729 onSend={(t) => {730 keepDraft.current = t;731 void run(t);732 }}733 onStop={stop}734 leftSlot={735 <>736 <ModelConfig model={sharedModel} settings={settings} onChange={setSettings} systemPrompt={systemPrompt} onSystemPromptChange={setSystemPrompt} allowSavePreset={false} />737 <Tooltip content={blindNext ? "Blind Arena on — identities hidden until you vote" : "Blind Arena — hide model names until you vote"}>738 <button type="button" aria-pressed={blindNext} disabled={running} onClick={() => setBlindNext((v) => !v)} className={cn("tap inline-flex h-8 items-center gap-1.5 rounded-md px-2 text-[12.5px] font-medium transition-colors [&_svg]:size-3.5", blindNext ? "bg-accent-soft text-accent" : "text-fg-muted hover:bg-bg-muted hover:text-fg")}>739 {blindNext ? <EyeOff /> : <Eye />}740 <span className="hidden sm:inline">Blind</span>741 </button>742 </Tooltip>743 </>744 }745 placeholder={selected.length ? `Ask ${selected.length === 1 ? "this model" : `all ${selected.length} models`} the same thing…` : isMobile ? "Pick models, then ask…" : "Pick models, then write one prompt for all of them…"}746 />747 <div className="flex min-h-4 flex-wrap items-center gap-x-3 gap-y-1 text-[11.5px] text-fg-muted">748 {runReason ? (749 <span className="inline-flex items-center gap-1.5">750 <AlertTriangle className="size-3.5 text-warning" /> {runReason}751 </span>752 ) : (753 <span className="hidden sm:inline">754 <Kbd>⌘↵</Kbd> runs all {selected.length} model{selected.length === 1 ? "" : "s"} in parallel{blindNext ? " · blind" : ""}755 </span>756 )}757 {systemPrompt.trim() ? <span className="text-fg-subtle">· system prompt set</span> : null}758 {hasImages && noVision.length ? (759 <span className="inline-flex items-center gap-1 text-warning">760 <AlertTriangle className="size-3.5" /> Images are skipped for {noVision.map((m) => m.displayName).join(", ")} (no vision).761 </span>762 ) : null}763 </div>764 </div>765 </div>766767 <ActionSheet open={menuOpen} onOpenChange={setMenuOpen} title="Arena" items={menuItems} />768 <ArenaShareSheet sessionId={shareFor} open={Boolean(shareFor)} onOpenChange={(o) => !o && setShareFor(null)} />769 </div>770 );771}772773function ArenaEmpty({ noProviders }: { noProviders: boolean }) {774 return (775 <section className="rounded-xl border border-dashed border-border px-5 py-8 sm:px-8" aria-label="About the Arena">776 <div className="mx-auto max-w-2xl">777 <h2 className="text-balance text-lg font-semibold tracking-tight">778 Same prompt, <span className="text-gradient">every model</span>, one screen.779 </h2>780 <p className="mt-1.5 text-[13.5px] text-fg-muted">Pick up to four models from your connected providers, write one prompt and watch them answer live. Then compare speed, cost and quality — vote, and let the scoreboard remember.</p>781 <ul className="mt-5 grid gap-3 sm:grid-cols-3">782 <Feature icon={<Columns3 className="size-4" />} title="Live, in parallel" text="Each model streams with reasoning, citations and live tok/s. Swipe between them on your phone." />783 <Feature icon={<Timer className="size-4" />} title="Hard numbers" text="Time to first token, tokens per second, in/out tokens and cost — estimated live, exact when done." />784 <Feature icon={<Trophy className="size-4" />} title="Your verdict" text="Vote per criterion, try Blind Arena, share a comparison, and build a personal scoreboard." />785 </ul>786 {noProviders ? (787 <div className="mt-5 flex flex-wrap items-center gap-3 rounded-lg border border-border bg-bg-elevated px-4 py-3">788 <KeyRound className="size-4 text-accent" />789 <p className="min-w-0 flex-1 text-[13px] text-fg-muted">Connect at least one provider to unlock the Arena. Keys are encrypted and only decrypted server-side.</p>790 <Button asChild variant="accent" size="sm">791 <Link href="/app/settings/providers">Connect a provider</Link>792 </Button>793 </div>794 ) : (795 <p className="mt-5 text-[12.5px] text-fg-subtle">796 Tip: shared settings (temperature, reasoning effort…) are filtered per model — anything a model does not support is simply not sent. <Kbd>⌘↵</Kbd> runs, <Kbd>esc</Kbd> stops everything.797 </p>798 )}799 </div>800 </section>801 );802}803804function Feature({ icon, title, text }: { icon: React.ReactNode; title: string; text: string }) {805 return (806 <li className="rounded-lg border border-border bg-bg-elevated p-3">807 <div className="flex items-center gap-2 text-[13px] font-medium">808 <span className="flex size-6 items-center justify-center rounded-md bg-bg-muted text-fg-muted">{icon}</span>809 {title}810 </div>811 <p className="mt-1.5 text-[12.5px] leading-5 text-fg-muted">{text}</p>812 </li>813 );814}815