TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import { Check, Columns3, Loader2, Play, Square, X } from "lucide-react";4import { useApp } from "@/components/app/store";5import { api, streamEvents, ClientApiError } from "@/lib/client/api";6import type { ChatAdoptResponse, PolyModel, PolyProviderErrorShape } from "@/lib/client/types";7import type { ChatSettings } from "./model-config";8import { ModelSelector } from "./model-selector";9import { MessageError } from "./message-error";10import { Markdown } from "@/components/markdown/markdown";11import { ProviderIcon } from "@/components/brand/provider-icon";12import { Button } from "@/components/ui/button";13import { Textarea } from "@/components/ui/input";14import { Tooltip } from "@/components/ui/tooltip";15import { toast } from "@/components/ui/toast";16import { useIsMobile, useSnapCarousel } from "@/lib/client/hooks";17import { PROVIDERS } from "@/lib/client/providers";18import { cn, formatMs, formatTokens, formatUsd } from "@/lib/utils";1920/** Wire shape of an Arena response row (subset we need). */21interface ArenaResponseLite {22 id: string;23 modelKey: string;24 content: string;25 status: string;26 usage?: Record<string, number> | null;27 latencyMs?: number | null;28 ttftMs?: number | null;29 costUsd?: number | null;30 error?: { code: string; message: string } | null;31}3233type ArenaEvent =34 | { type: "meta"; responseId: string; modelKey: string }35 | { type: "text-delta"; text: string }36 | { type: "reasoning-delta"; text: string }37 | { type: "citation"; citation: { url?: string; title?: string; snippet?: string } }38 | { type: "server-tool"; name: string; status: "started" | "completed" }39 | { type: "error"; error: PolyProviderErrorShape }40 | { type: "done"; response: ArenaResponseLite; status: "complete" | "stopped" | "error" };4142type Status = "waiting" | "thinking" | "streaming" | "done" | "error" | "stopped";4344interface Col {45 modelKey: string;46 responseId: string | null;47 status: Status;48 text: string;49 reasoning: string;50 response: ArenaResponseLite | null;51 error: PolyProviderErrorShape | { code: string; message: string } | null;52 errorAt?: number;53 startedAt: number;54 firstTokenAt?: number;55}5657export interface CompareInlineProps {58 prompt: string;59 /** Editable prompt (new chat / empty state). */60 onPromptChange?: (p: string) => void;61 systemPrompt?: string | null;62 settings?: ChatSettings;63 attachmentIds?: string[];64 initialModelKeys: string[];65 conversationId?: string | null;66 projectId?: string | null;67 onClose: () => void;68 /** Called after "Continue with <model>" succeeded. */69 onAdopted: (res: ChatAdoptResponse, modelKey: string) => void;70}7172const MAX = 4;73const MIN = 2;7475function cleanSettings(s: ChatSettings | undefined): ChatSettings | undefined {76 if (!s) return undefined;77 const { tools: _tools, ...rest } = s;78 void _tools;79 const out = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0))) as ChatSettings;80 return Object.keys(out).length ? out : undefined;81}8283function emptyCol(modelKey: string): Col {84 return { modelKey, responseId: null, status: "waiting", text: "", reasoning: "", response: null, error: null, startedAt: Date.now() };85}8687/**88 * Inline "Compare with…" — 2–4 models answer the same prompt inside the chat. Runs through the Arena API89 * (so the comparison is stored as an Arena session) and lets the user continue the conversation with any90 * answer via POST /api/chat/adopt.91 */92export function CompareInline({ prompt, onPromptChange, systemPrompt, settings, attachmentIds, initialModelKeys, conversationId, projectId, onClose, onAdopted }: CompareInlineProps) {93 const { modelsByKey, connectedProviders, preferences } = useApp();94 const isMobile = useIsMobile();95 const [selected, setSelected] = React.useState<string[]>(() => initialModelKeys.filter((k) => modelsByKey.has(k)).slice(0, MAX));96 const [columns, setColumns] = React.useState<Col[]>([]);97 const [running, setRunning] = React.useState(false);98 const [adopting, setAdopting] = React.useState<string | null>(null);99 const mapRef = React.useRef(new Map<string, Col>());100 const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);101 const abortRef = React.useRef<AbortController | null>(null);102 const { ref: rowRef, index, scrollTo } = useSnapCarousel<HTMLDivElement>(columns.length);103104 const flush = React.useCallback(() => {105 timer.current = null;106 setColumns([...mapRef.current.values()].map((c) => ({ ...c })));107 }, []);108 const schedule = React.useCallback(() => {109 if (!timer.current) timer.current = setTimeout(flush, 40);110 }, [flush]);111 React.useEffect(112 () => () => {113 if (timer.current) clearTimeout(timer.current);114 abortRef.current?.abort();115 },116 [],117 );118119 const selectedSet = React.useMemo(() => new Set(selected), [selected]);120 const toggle = (key: string) =>121 setSelected((s) => {122 if (s.includes(key)) return s.filter((k) => k !== key);123 if (s.length >= MAX) {124 toast.warning(`Up to ${MAX} models`, "Remove one before adding another.");125 return s;126 }127 return [...s, key];128 });129 const disconnected = selected.map((k) => modelsByKey.get(k)).filter((m): m is PolyModel => Boolean(m) && !connectedProviders.has(m!.provider));130 const canRun = !running && selected.length >= MIN && prompt.trim().length > 0 && disconnected.length === 0;131 const reason = running ? null : selected.length < MIN ? `Pick at least ${MIN} models` : !prompt.trim() ? "Write a prompt first" : disconnected.length ? `${disconnected.map((m) => PROVIDERS[m.provider].shortName).join(", ")} not connected` : null;132133 const streamOne = async (sessionId: string, modelKey: string, signal: AbortSignal) => {134 const col = () => mapRef.current.get(modelKey);135 try {136 await streamEvents(137 "/api/arena/stream",138 { sessionId, modelKey },139 (raw) => {140 const c = col();141 if (!c) return;142 const ev = raw as unknown as ArenaEvent;143 switch (ev.type) {144 case "meta":145 c.responseId = ev.responseId;146 break;147 case "text-delta":148 if (!c.firstTokenAt) c.firstTokenAt = Date.now();149 c.text += ev.text;150 c.status = "streaming";151 break;152 case "reasoning-delta":153 if (!c.firstTokenAt) c.firstTokenAt = Date.now();154 c.reasoning += ev.text;155 if (c.status === "waiting") c.status = "thinking";156 break;157 case "error":158 c.error = ev.error;159 c.errorAt = Date.now();160 break;161 case "done":162 c.response = ev.response;163 c.responseId = ev.response.id;164 c.status = ev.status === "complete" ? "done" : ev.status === "stopped" ? "stopped" : "error";165 if (ev.response.error && !c.error) {166 c.error = ev.response.error;167 c.errorAt = Date.now();168 }169 break;170 default:171 break;172 }173 schedule();174 },175 signal,176 );177 const c = col();178 if (c && (c.status === "waiting" || c.status === "thinking" || c.status === "streaming")) c.status = c.error ? "error" : "stopped";179 } catch (e) {180 const c = col();181 if (!c) return;182 if ((e as Error).name === "AbortError") c.status = "stopped";183 else {184 const err = e as ClientApiError;185 c.status = "error";186 c.error = { code: err.code ?? "HTTP_ERROR", message: err.message };187 c.errorAt = Date.now();188 }189 }190 flush();191 };192193 const run = async () => {194 if (!canRun) return;195 setRunning(true);196 const keys = [...selected];197 mapRef.current = new Map(keys.map((k) => [k, emptyCol(k)]));198 flush();199 const controller = new AbortController();200 abortRef.current = controller;201 try {202 const res = await api<{ session: { id: string } }>("/api/arena", {203 method: "POST",204 json: { prompt: prompt.trim(), systemPrompt: systemPrompt?.trim() || undefined, modelKeys: keys, settings: cleanSettings(settings), attachmentIds: attachmentIds?.length ? attachmentIds.slice(0, 6) : undefined },205 });206 await Promise.allSettled(keys.map((k) => streamOne(res.session.id, k, controller.signal)));207 } catch (e) {208 const err = e as ClientApiError;209 toast.error("Could not start the comparison", err.message);210 mapRef.current = new Map();211 flush();212 } finally {213 setRunning(false);214 abortRef.current = null;215 }216 };217218 const stop = () => abortRef.current?.abort();219220 const adopt = async (c: Col) => {221 if (!c.responseId && !c.text) return;222 setAdopting(c.modelKey);223 try {224 const res = await api<ChatAdoptResponse>("/api/chat/adopt", {225 method: "POST",226 json: { conversationId: conversationId ?? undefined, modelKey: c.modelKey, arenaResponseId: c.responseId ?? undefined, content: c.responseId ? undefined : c.text, userText: conversationId ? undefined : prompt.trim(), systemPrompt: conversationId ? undefined : systemPrompt ?? null, settings: conversationId ? undefined : cleanSettings(settings), projectId: conversationId ? undefined : projectId ?? null },227 });228 onAdopted(res, c.modelKey);229 } catch (e) {230 toast.error("Could not continue with this answer", (e as Error).message);231 } finally {232 setAdopting(null);233 }234 };235236 const allSettled = columns.length > 0 && columns.every((c) => c.status === "done" || c.status === "error" || c.status === "stopped");237238 return (239 <section className="rounded-2xl border border-border bg-bg-elevated shadow-sm" aria-label="Compare models" data-no-edge-swipe>240 {/* Header */}241 <header className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2">242 <span className="flex size-7 items-center justify-center rounded-md bg-accent-soft text-accent">243 <Columns3 className="size-4" />244 </span>245 <h3 className="text-[14px] font-semibold tracking-tight">Compare with…</h3>246 <span className="hidden text-[12px] text-fg-muted sm:inline">{selected.length}/{MAX} models</span>247 <div className="ml-auto flex items-center gap-1.5">248 {running ? (249 <Button size="sm" variant="secondary" onClick={stop}>250 <Square className="size-3.5 fill-current" /> Stop251 </Button>252 ) : (253 <Tooltip content={reason ?? "Run the comparison"}>254 <span className="inline-flex">255 <Button size="sm" variant="accent" disabled={!canRun} onClick={run}>256 <Play className="size-3.5 fill-current" /> {columns.length ? "Run again" : "Run"}257 </Button>258 </span>259 </Tooltip>260 )}261 <Button size="icon-sm" variant="ghost" onClick={onClose} aria-label="Close comparison" disabled={running}>262 <X />263 </Button>264 </div>265 </header>266267 {/* Setup */}268 <div className="space-y-2 px-3 py-2.5">269 <div className="flex flex-wrap items-center gap-1.5">270 {selected.map((k) => {271 const m = modelsByKey.get(k);272 return (273 <span key={k} className="inline-flex h-8 items-center gap-1.5 rounded-full border border-border bg-bg-subtle pl-2 pr-1 text-[12.5px]">274 <ProviderIcon provider={m?.provider ?? k.split("/")[0]} size={13} />275 <span className="max-w-[140px] truncate">{m?.displayName ?? k}</span>276 <button type="button" onClick={() => toggle(k)} disabled={running} className="tap rounded-full p-0.5 text-fg-subtle hover:text-danger" aria-label={`Remove ${m?.displayName ?? k}`}>277 <X className="size-3" />278 </button>279 </span>280 );281 })}282 {!running ? <ModelSelector value={null} multiple selected={selectedSet} onToggle={toggle} size="sm" buttonLabel={selected.length < MAX ? "+ Add model" : "Models"} className="h-8 rounded-full" /> : null}283 </div>284 {onPromptChange ? (285 <Textarea value={prompt} onChange={(e) => onPromptChange(e.target.value)} placeholder="One prompt for every model…" className="min-h-[64px] text-[14px]" disabled={running} aria-label="Comparison prompt" />286 ) : (287 <p className="line-clamp-3 rounded-lg bg-bg-subtle px-3 py-2 text-[13px] text-fg-muted" title={prompt}>288 {prompt}289 </p>290 )}291 {reason && !columns.length ? <p className="text-[12px] text-fg-subtle">{reason}.</p> : null}292 </div>293294 {/* Results */}295 {columns.length ? (296 isMobile ? (297 <div className="border-t border-border">298 <div className="sticky top-0 z-10 flex gap-1 overflow-x-auto bg-bg-elevated/95 px-2 py-2 backdrop-blur scrollbar-none" role="tablist">299 {columns.map((c, i) => {300 const m = modelsByKey.get(c.modelKey);301 return (302 <button key={c.modelKey} role="tab" aria-selected={index === i} onClick={() => scrollTo(i)} className={cn("inline-flex h-9 shrink-0 items-center gap-1.5 rounded-full border px-3 text-[13px] font-medium", index === i ? "border-fg bg-fg text-bg" : "border-border text-fg-muted")}>303 <StatusDot status={c.status} />304 <span className="max-w-[140px] truncate">{m?.displayName ?? c.modelKey}</span>305 </button>306 );307 })}308 </div>309 <div ref={rowRef} className="snap-row">310 {columns.map((c) => (311 <div key={c.modelKey} className="px-3 pb-3">312 <Panel col={c} model={modelsByKey.get(c.modelKey)} wrapCode={preferences.codeWrap} showCosts={preferences.showCosts} onAdopt={allSettled ? () => adopt(c) : undefined} adopting={adopting === c.modelKey} />313 </div>314 ))}315 </div>316 </div>317 ) : (318 <div className={cn("grid gap-3 border-t border-border p-3", columns.length === 2 ? "md:grid-cols-2" : columns.length === 3 ? "md:grid-cols-3" : "md:grid-cols-2 xl:grid-cols-4")}>319 {columns.map((c) => (320 <Panel key={c.modelKey} col={c} model={modelsByKey.get(c.modelKey)} wrapCode={preferences.codeWrap} showCosts={preferences.showCosts} onAdopt={allSettled ? () => adopt(c) : undefined} adopting={adopting === c.modelKey} />321 ))}322 </div>323 )324 ) : null}325 {allSettled ? <p className="px-3 pb-3 text-[12px] text-fg-subtle">Saved to the Arena history. Pick an answer to continue the conversation with that model.</p> : null}326 </section>327 );328}329330const STATUS: Record<Status, { label: string; dot: string; pulse: boolean }> = {331 waiting: { label: "Waiting", dot: "bg-fg-subtle", pulse: true },332 thinking: { label: "Thinking", dot: "bg-accent", pulse: true },333 streaming: { label: "Streaming", dot: "bg-info", pulse: true },334 done: { label: "Done", dot: "bg-success", pulse: false },335 error: { label: "Failed", dot: "bg-danger", pulse: false },336 stopped: { label: "Stopped", dot: "bg-warning", pulse: false },337};338339function StatusDot({ status }: { status: Status }) {340 const s = STATUS[status];341 return <span className={cn("inline-block size-2 shrink-0 rounded-full", s.dot, s.pulse && "animate-pulse-soft")} aria-hidden />;342}343344function Panel({ col: c, model, wrapCode, showCosts, onAdopt, adopting }: { col: Col; model?: PolyModel; wrapCode?: boolean; showCosts?: boolean; onAdopt?: () => void; adopting?: boolean }) {345 const name = model?.displayName ?? c.modelKey.split("/").slice(1).join("/");346 const live = c.status === "waiting" || c.status === "thinking" || c.status === "streaming";347 const u = (c.response?.usage ?? null) as { inputTokens?: number; outputTokens?: number } | null;348 const gen = c.response?.latencyMs ? Math.max(1, c.response.latencyMs - (c.response.ttftMs ?? 0)) : null;349 const tps = u?.outputTokens && gen ? Math.round((u.outputTokens / gen) * 1000) : null;350 return (351 <article className="flex min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-bg" aria-label={`${name} response`} aria-busy={live}>352 <header className="flex items-center gap-2 border-b border-border px-3 py-2">353 <ProviderIcon provider={model?.provider ?? c.modelKey.split("/")[0]} size={14} />354 <span className="min-w-0 flex-1 truncate text-[13px] font-semibold">{name}</span>355 <span className="inline-flex items-center gap-1.5 text-[11px] text-fg-subtle">356 <StatusDot status={c.status} /> {STATUS[c.status].label}357 </span>358 </header>359 <div className="max-h-[420px] min-h-[96px] overflow-y-auto px-3 py-2 text-[14px] scrollbar-thin">360 {c.status === "waiting" && !c.text ? (361 <p className="flex items-center gap-2 text-[13px] text-fg-muted">362 <Loader2 className="size-3.5 animate-spin" /> Waiting for the model…363 </p>364 ) : null}365 {c.status === "thinking" && !c.text ? <p className="text-[13px] text-fg-muted">Thinking…</p> : null}366 {c.text ? <Markdown content={c.text} wrap={wrapCode} streaming={live} className="text-[14px]" /> : null}367 {c.error && !live ? (368 <div className="mt-2">369 <MessageError error={c.error} provider={model?.provider ?? c.modelKey.split("/")[0]} since={c.errorAt} compact />370 </div>371 ) : null}372 </div>373 <footer className="flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-border px-3 py-2 text-[11px] tabular-nums text-fg-subtle">374 {c.response?.ttftMs ? <span title="Time to first token">TTFT {formatMs(c.response.ttftMs)}</span> : null}375 {c.response?.latencyMs ? <span>{formatMs(c.response.latencyMs)}</span> : null}376 {tps ? <span>{tps} tok/s</span> : null}377 {u ? (378 <span>379 {formatTokens(u.inputTokens ?? 0)} in · {formatTokens(u.outputTokens ?? 0)} out380 </span>381 ) : null}382 {showCosts && c.response?.costUsd !== null && c.response?.costUsd !== undefined ? <span>≈ {formatUsd(c.response.costUsd, { precise: c.response.costUsd < 0.01 })}</span> : null}383 <span className="flex-1" />384 {onAdopt && c.status === "done" && c.text ? (385 <Button size="xs" variant="outline" className="bg-bg-elevated" onClick={onAdopt} loading={adopting}>386 <Check /> Continue with {name}387 </Button>388 ) : null}389 </footer>390 </article>391 );392}393