SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
39.8 KB · 815 lines tsx
Raw Blame History
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