"use client"; import * as React from "react"; import { Brain, Check, ChevronDown, ChevronRight, Coins, Copy, ExternalLink, Eye, Globe, Loader2, Trophy, Wrench, X, Zap } from "lucide-react"; import type { PolyModel } from "@/lib/client/types"; import { Markdown } from "@/components/markdown/markdown"; import { ProviderIcon } from "@/components/brand/provider-icon"; import { Badge } from "@/components/ui/badge"; import { Tooltip } from "@/components/ui/tooltip"; import { PROVIDERS } from "@/lib/client/providers"; import { liveMetrics } from "@/lib/arena/metrics"; import { blindLabel, type Criterion } from "@/lib/arena/scoring"; import { cn, formatMs, formatTokens } from "@/lib/utils"; import { isFinal, type ColumnState } from "./types"; import { MetricsStrip, STATUS_META, StatusDot } from "./metrics-strip"; import { VotePanel } from "./vote-panel"; import { BlindAvatar, Flip } from "./blind"; export { STATUS_META, StatusDot }; interface Props { column: ColumnState; model?: PolyModel; /** Display position (0-based) — drives the Blind Arena letter. */ index: number; /** Blind Arena with identity still hidden. */ hidden?: boolean; /** Reveal flip in progress. */ flipping?: boolean; onReveal?: () => void; winners: { fastest: string | null; cheapest: string | null }; isWinner?: boolean; criteria: Criterion[]; /** Criterion ids this response has won. */ won: ReadonlySet; onVote: (responseId: string, criterionId: string, on: boolean) => void; onAddCriterion?: (label: string) => void; onRemoveCriterion?: (id: string) => void; voteBusy?: boolean; /** Prompt (+ system prompt, attachments) token estimate for live cost. */ inputTokens: number; /** Shared clock from the parent (`useTick`). */ now: number; wrapCode?: boolean; showReasoning?: boolean; showCosts?: boolean; /** Sync-scroll plumbing (optional). */ bodyRef?: (el: HTMLDivElement | null) => void; onBodyScroll?: (el: HTMLDivElement) => void; className?: string; style?: React.CSSProperties; } export const ArenaColumn = React.memo(function ArenaColumn({ column: c, model, index, hidden, flipping, onReveal, winners, isWinner, criteria, won, onVote, onAddCriterion, onRemoveCriterion, voteBusy, inputTokens, now, wrapCode, showReasoning = true, showCosts = true, bodyRef, onBodyScroll, className, style }: Props) { const provider = model?.provider ?? c.response?.provider ?? c.modelKey.split("/")[0]; const realName = model?.displayName ?? c.modelKey.split("/").slice(1).join("/"); const name = hidden ? blindLabel(index) : realName; const live = c.status === "waiting" || c.status === "thinking" || c.status === "streaming"; const final = isFinal(c.status); const metrics = React.useMemo(() => liveMetrics(c, model, inputTokens, now), [c, model, inputTokens, now]); const [copied, setCopied] = React.useState(false); // Keep the body pinned to the bottom while streaming unless the user scrolled up. const scrollEl = React.useRef(null); const pinned = React.useRef(true); React.useEffect(() => { const el = scrollEl.current; if (el && live && pinned.current) el.scrollTop = el.scrollHeight; }, [c.text, c.reasoning, live]); const copy = async () => { await navigator.clipboard.writeText(c.text).catch(() => {}); setCopied(true); setTimeout(() => setCopied(false), 1400); }; const status = STATUS_META[c.status]; return (
{/* Header */}
{hidden ? : }
{name} {!hidden && model?.capabilities.reasoning ? : null}
{status.label} {live ? · {(metrics.elapsedMs / 1000).toFixed(1)} s : null} {!hidden ? · {PROVIDERS[provider as keyof typeof PROVIDERS]?.shortName ?? provider} : · identity hidden}
{isWinner ? ( Winner ) : null} {winners.fastest === c.modelKey ? ( Fastest ) : null} {winners.cheapest === c.modelKey && showCosts ? ( Cheapest ) : null} {hidden && onReveal && final ? ( ) : null} {c.text ? ( ) : null}
{/* Body */}
{ scrollEl.current = el; bodyRef?.(el); }} onScroll={(e) => { const el = e.currentTarget; pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48; onBodyScroll?.(el); }} className="min-h-[140px] max-h-[min(58dvh,720px)] flex-1 space-y-2.5 overflow-y-auto overscroll-contain px-3.5 py-3 scrollbar-thin md:max-h-[min(62vh,720px)]" > {(c.reasoning || c.status === "thinking") && showReasoning ? : null} {c.serverTools.length ? (
{dedupe(c.serverTools).map((t, i) => ( {t.name === "web_search" ? : } {t.name.replace(/_/g, " ")} {t.status === "started" ? : null} ))}
) : null} {c.status === "waiting" ? (
Waiting for {name}…
) : null} {c.text ? : null} {final && !c.text && !c.error ?

{c.status === "stopped" ? "Stopped before any output." : "The model returned an empty response."}

: null} {c.error ? (
{c.error.message}
{c.error.code}
) : null} {c.citations.length ? : null}
{/* Footer: live metrics + votes */} {c.status !== "idle" ? (
{final && c.responseId ? onVote(c.responseId!, id, on)} onAddCriterion={onAddCriterion} onRemoveCriterion={onRemoveCriterion} /> : null} {final && c.status === "error" && !c.responseId ?

Latency {formatMs(metrics.elapsedMs)}

: null}
) : null}
); }); function ReasoningBlock({ text, streaming }: { text: string; streaming: boolean }) { const [open, setOpen] = React.useState(streaming); React.useEffect(() => { if (!streaming) { // collapse automatically once the answer starts // eslint-disable-next-line react-hooks/set-state-in-effect setOpen(false); } }, [streaming]); const ref = React.useRef(null); React.useEffect(() => { if (open && streaming && ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [text, open, streaming]); return (
{open ? (
{text ? : Reasoning summary will appear here when the provider exposes it.}
) : null}
); } function Citations({ items }: { items: { url?: string; title?: string; snippet?: string }[] }) { const unique = React.useMemo(() => { const seen = new Set(); return items.filter((c) => { const k = c.url ?? c.title ?? ""; if (!k || seen.has(k)) return false; seen.add(k); return true; }); }, [items]); if (!unique.length) return null; return (
{unique.slice(0, 10).map((c, i) => ( {c.title || safeHost(c.url)} ))}
); } function safeHost(url?: string): string { try { return url ? new URL(url).hostname : "source"; } catch { return "source"; } } function dedupe(list: { name: string; status: string }[]) { const map = new Map(); for (const t of list) map.set(t.name, t); return [...map.values()]; }