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.5 KB · 832 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import {4  ArrowUp,5  BarChart3,6  Boxes,7  Check,8  ChevronDown,9  ChevronRight,10  Copy,11  FolderOpen,12  GitBranch,13  Library,14  Menu,15  MessageSquare,16  Mic,17  PanelLeft,18  Plus,19  RotateCcw,20  Search,21  Sparkles,22  SquarePen,23  Star,24  Swords,25  Trophy,26  WandSparkles,27  Zap,28} from "lucide-react";29import { Logo } from "@/components/brand/logo";30import { ProviderIcon } from "@/components/brand/provider-icon";31import { Badge } from "@/components/ui/badge";32import { MOBILE_TABS } from "@/components/app/bottom-nav";33import { cn } from "@/lib/utils";34import { approxTokens } from "./fake-stream";35import { MOCK_MODELS, MOCK_PROJECTS, RECENT_CONVERSATIONS, SCOREBOARD, USAGE_BY_PROVIDER, USAGE_COST_SERIES, USAGE_DAY_LABELS, USAGE_KPIS, USAGE_SAVINGS, USAGE_SERIES, PROVIDER_LABEL, type MockModel } from "./mock-data";3637/* ------------------------------------------------------------------------------------------------38 * Ultra-light inline markdown for the mocks (bold, inline code, paragraphs, pipe tables).39 * Not the real renderer — just enough to look like it.40 * ---------------------------------------------------------------------------------------------- */41function renderInline(line: string, keyPrefix: string) {42  const parts = line.split(/(\*\*[^*]+\*\*|`[^`]+`)/g).filter(Boolean);43  return parts.map((p, i) => {44    if (p.startsWith("**") && p.endsWith("**")) return <strong key={`${keyPrefix}-${i}`}>{p.slice(2, -2)}</strong>;45    if (p.startsWith("`") && p.endsWith("`")) return <code key={`${keyPrefix}-${i}`}>{p.slice(1, -1)}</code>;46    return <React.Fragment key={`${keyPrefix}-${i}`}>{p}</React.Fragment>;47  });48}4950function MockTable({ block, streaming }: { block: string; streaming: boolean }) {51  const lines = block.split("\n").filter((l) => l.trim().startsWith("|"));52  const rows = lines.filter((l) => !/^\|\s*-{2,}/.test(l.trim())).map((l) => l.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim()));53  if (!rows.length) return null;54  const [head, ...body] = rows;55  return (56    <table className={cn(streaming && "opacity-90")}>57      <thead>58        <tr>59          {head.map((c, i) => (60            <th key={i}>{renderInline(c, `h${i}`)}</th>61          ))}62        </tr>63      </thead>64      <tbody>65        {body.map((r, ri) => (66          <tr key={ri}>67            {r.map((c, ci) => (68              <td key={ci}>{renderInline(c, `c${ri}-${ci}`)}</td>69            ))}70          </tr>71        ))}72      </tbody>73    </table>74  );75}7677export function MockMarkdown({ text, streaming, className }: { text: string; streaming: boolean; className?: string }) {78  const blocks = text.split(/\n\n/);79  return (80    <div className={cn("prose-chat text-[13px] leading-[1.6] sm:text-[13.5px]", className)}>81      {blocks.map((b, i) => {82        const last = i === blocks.length - 1;83        if (b.trim().startsWith("|")) {84          return (85            <React.Fragment key={i}>86              <MockTable block={b} streaming={streaming && last} />87              {streaming && last ? <span className="caret" aria-hidden /> : null}88            </React.Fragment>89          );90        }91        return (92          <p key={i}>93            {renderInline(b, String(i))}94            {streaming && last ? <span className="caret" aria-hidden /> : null}95          </p>96        );97      })}98    </div>99  );100}101102/* ------------------------------------------------------------------------------------------------103 * Atoms that mirror the real app104 * ---------------------------------------------------------------------------------------------- */105export function ModelPill({ model, className, size = "md" }: { model: MockModel; className?: string; size?: "sm" | "md" }) {106  return (107    <span className={cn("inline-flex items-center gap-1.5 rounded-md border border-border bg-bg-elevated font-medium", size === "sm" ? "h-6 px-1.5 text-[11.5px]" : "h-7 px-2 text-[12.5px]", className)}>108      <ProviderIcon provider={model.provider} size={size === "sm" ? 12 : 13} />109      <span className="truncate">{model.name}</span>110      <ChevronDown className={cn("shrink-0 text-fg-subtle", size === "sm" ? "size-3" : "size-3.5")} aria-hidden />111    </span>112  );113}114115export function ContextIndicator({ used, total, className }: { used: string; total: string; className?: string }) {116  return (117    <span className={cn("inline-flex items-center gap-1.5 font-mono text-[10.5px] tabular-nums text-fg-subtle", className)} title="Context used">118      <span className="h-1 w-10 overflow-hidden rounded-full bg-bg-muted">119        <span className="block h-full w-[9%] rounded-full bg-accent" />120      </span>121      {used} / {total}122    </span>123  );124}125126/** The metadata line shown under an assistant message in the real app. */127export function MessageMeta({ chars, elapsedMs, ttftMs, done, model, className }: { chars: number; elapsedMs: number; ttftMs: number; done: boolean; model: MockModel; className?: string }) {128  const out = approxTokens(chars);129  const inTok = 60 + Math.round(chars / 40);130  const secs = Math.max(0.1, elapsedMs / 1000);131  const tps = chars > 0 ? Math.round(out / secs) : 0;132  const cost = (out / 1_000_000) * parseFloat(model.output.slice(1)) + (inTok / 1_000_000) * parseFloat(model.input.slice(1));133  return (134    <div className={cn("flex flex-wrap items-center gap-x-2.5 gap-y-1 font-mono text-[10.5px] tabular-nums text-fg-subtle", className)} aria-label="Message metadata">135      <span className="inline-flex items-center gap-1 text-fg-muted">136        <ProviderIcon provider={model.provider} size={11} />137        {model.name}138      </span>139      <span>140        {inTok} in · {out} out141      </span>142      <span>{(secs + ttftMs / 1000).toFixed(1)} s</span>143      <span>TTFT {(ttftMs / 1000).toFixed(1)} s</span>144      <span>{tps} tok/s</span>145      <span className={cn(done && "text-fg-muted")}>≈ ${cost.toFixed(4)}</span>146      {done ? (147        <span className="ml-auto hidden items-center gap-2 text-fg-subtle sm:inline-flex" aria-hidden>148          <Copy className="size-3" />149          <RotateCcw className="size-3" />150          <GitBranch className="size-3" />151        </span>152      ) : null}153    </div>154  );155}156157/** Desktop composer as in the real app: auto-grow area, tools row, active model pill, send. */158export function ComposerMock({ text, model, streaming, compact }: { text?: string; model: MockModel; streaming?: boolean; compact?: boolean }) {159  return (160    <div className="rounded-2xl border border-border bg-bg-elevated shadow-xs" aria-hidden>161      <div className={cn("px-3.5 pt-3 text-[13px] leading-6", compact ? "min-h-[36px]" : "min-h-[44px]", text ? "text-fg" : "text-fg-subtle")}>162        {text ?? "Ask anything…"}163        {text && !streaming ? <span className="caret" aria-hidden /> : null}164      </div>165      <div className="flex items-center gap-1.5 px-2 pb-2 pt-1">166        <span className="inline-flex size-7 items-center justify-center rounded-md text-fg-muted">167          <Plus className="size-4" />168        </span>169        <ModelPill model={model} size="sm" />170        <span className="hidden h-6 items-center gap-1 rounded-md border border-dashed border-border px-1.5 text-[11px] text-fg-muted lg:inline-flex">Web search</span>171        <span className="ml-auto inline-flex size-7 items-center justify-center rounded-md text-fg-muted">172          <Mic className="size-4" />173        </span>174        <span className={cn("inline-flex size-7 items-center justify-center rounded-full", streaming ? "bg-bg-muted text-fg" : "bg-fg text-bg")}>{streaming ? <span className="size-2.5 rounded-[2px] bg-current" /> : <ArrowUp className="size-4" />}</span>175      </div>176    </div>177  );178}179180/** Phone composer: `[+] Ask anything… [mic] [send]` — one row, 16 px-equivalent text. */181export function MobileComposerMock({ text, streaming }: { text?: string; streaming?: boolean }) {182  return (183    <div className="flex items-end gap-1.5 px-2.5 pb-2 pt-1.5" aria-hidden>184      <span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-bg-muted text-fg-muted">185        <Plus className="size-[18px]" />186      </span>187      <div className={cn("flex min-h-9 flex-1 items-center rounded-[18px] border border-border bg-bg-elevated px-3 text-[13px]", text ? "text-fg" : "text-fg-subtle")}>188        <span className="truncate">{text ?? "Ask anything…"}</span>189      </div>190      <span className="flex size-9 shrink-0 items-center justify-center rounded-full text-fg-muted">191        <Mic className="size-[18px]" />192      </span>193      <span className={cn("flex size-9 shrink-0 items-center justify-center rounded-full", streaming ? "bg-bg-muted text-fg" : "bg-fg text-bg")}>{streaming ? <span className="size-3 rounded-[3px] bg-current" /> : <ArrowUp className="size-[18px]" />}</span>194    </div>195  );196}197198/** Mirrors `components/app/bottom-nav.tsx` (same tabs, same geometry) inside the phone frame. */199export function BottomNavMock({ active }: { active: "Chat" | "Arena" | "Models" | "Usage" | "Account" }) {200  return (201    <nav aria-hidden className="glass-strong shrink-0 border-t border-hairline pb-4">202      <ul className="grid h-[52px] grid-cols-5">203        {MOBILE_TABS.map((t) => {204          const on = t.label === active;205          return (206            <li key={t.href} className="flex flex-col items-center justify-center gap-0.5 text-[9.5px] font-medium tracking-tight">207              <span className={cn("flex h-6 w-10 items-center justify-center rounded-full", on ? "bg-accent-soft text-accent" : "text-fg-subtle")}>208                <t.icon className="size-[17px]" strokeWidth={on ? 2.2 : 1.8} />209              </span>210              <span className={on ? "text-fg" : "text-fg-subtle"}>{t.label}</span>211            </li>212          );213        })}214      </ul>215    </nav>216  );217}218219export function MobileHeaderMock({ center, right, title }: { center?: React.ReactNode; right?: React.ReactNode; title?: string }) {220  return (221    <div className="flex h-11 shrink-0 items-center justify-between px-2.5 hairline-b" aria-hidden>222      <span className="flex size-8 items-center justify-center rounded-md text-fg-muted">223        <Menu className="size-[18px]" />224      </span>225      {center ?? <span className="text-[13px] font-semibold tracking-tight">{title}</span>}226      {right ?? (227        <span className="flex size-8 items-center justify-center rounded-md text-fg-muted">228          <SquarePen className="size-[17px]" />229        </span>230      )}231    </div>232  );233}234235/* ------------------------------------------------------------------------------------------------236 * Desktop sidebar (mirrors components/app/sidebar.tsx)237 * ---------------------------------------------------------------------------------------------- */238const SIDEBAR_NAV = [239  { label: "Chat", icon: MessageSquare },240  { label: "Arena", icon: Swords },241  { label: "Models", icon: Boxes },242  { label: "Prompts", icon: WandSparkles },243  { label: "Presets", icon: Sparkles },244  { label: "Library", icon: Library },245  { label: "Usage", icon: BarChart3 },246] as const;247248export function SidebarMock({ active = "Chat", activeConversation = RECENT_CONVERSATIONS[0], className }: { active?: (typeof SIDEBAR_NAV)[number]["label"]; activeConversation?: string | null; className?: string }) {249  return (250    <aside className={cn("flex w-[13.5rem] shrink-0 flex-col border-r border-border bg-bg-subtle p-2.5 text-[12px]", className)} aria-hidden>251      <div className="flex items-center justify-between px-1">252        <Logo size={18} className="[&_span]:text-[13px]" />253        <PanelLeft className="size-3.5 text-fg-subtle" />254      </div>255      <div className="mt-2.5 flex h-7 items-center gap-2 rounded-md bg-fg px-2 text-[11.5px] font-medium text-bg">256        <SquarePen className="size-3.5" /> New chat <kbd className="kbd ml-auto border-bg/20 bg-bg/10 text-bg/80">⌘N</kbd>257      </div>258      <div className="mt-1.5 flex h-7 items-center gap-2 rounded-md border border-border bg-bg px-2 text-fg-subtle">259        <Search className="size-3.5" /> Search <kbd className="kbd ml-auto">⌘K</kbd>260      </div>261      <ul className="mt-2.5 space-y-px">262        {SIDEBAR_NAV.map((n) => (263          <li key={n.label} className={cn("flex h-7 items-center gap-2 rounded-md px-2", n.label === active ? "bg-bg-muted font-medium text-fg" : "text-fg-muted")}>264            <n.icon className="size-3.5" /> {n.label}265          </li>266        ))}267      </ul>268      <div className="mt-3 flex items-center justify-between px-2 text-[10.5px] font-semibold uppercase tracking-[0.08em] text-fg-subtle">269        Projects <Plus className="size-3" />270      </div>271      <ul className="mt-1 space-y-px">272        {MOCK_PROJECTS.map((p) => (273          <li key={p} className="flex h-6.5 items-center gap-2 rounded-md px-2 text-fg-muted">274            <FolderOpen className="size-3.5 text-fg-subtle" /> {p}275          </li>276        ))}277      </ul>278      <div className="mt-3 px-2 text-[10.5px] font-semibold uppercase tracking-[0.08em] text-fg-subtle">Recent</div>279      <ul className="mt-1 min-h-0 flex-1 space-y-px overflow-hidden">280        {RECENT_CONVERSATIONS.map((t) => (281          <li key={t} className={cn("flex h-6.5 items-center gap-2 truncate rounded-md px-2", t === activeConversation ? "bg-bg-muted font-medium text-fg" : "text-fg-muted")}>282            <MessageSquare className="size-3.5 shrink-0 text-fg-subtle" /> <span className="truncate">{t}</span>283          </li>284        ))}285      </ul>286      <div className="mt-2 flex items-center gap-2 rounded-md px-2 py-1.5 text-fg-muted">287        <span className="flex size-5 items-center justify-center rounded-full bg-accent-soft text-[9px] font-semibold text-accent">SP</span>288        <span className="truncate">simon@…</span>289        <ChevronDown className="ml-auto size-3 text-fg-subtle" />290      </div>291    </aside>292  );293}294295/* ------------------------------------------------------------------------------------------------296 * Chat — desktop and phone297 * ---------------------------------------------------------------------------------------------- */298export interface ChatMockProps {299  prompt: string;300  reply: string;301  progress: number;302  elapsedMs: number;303  ttftMs: number;304  model: MockModel;305  className?: string;306}307308function AssistantTurn({ reply, progress, elapsedMs, ttftMs, model, dense }: Omit<ChatMockProps, "prompt" | "className"> & { dense?: boolean }) {309  const shown = reply.slice(0, progress);310  const done = progress >= reply.length;311  return (312    <div className="flex gap-2.5">313      <span className={cn("mt-0.5 flex shrink-0 items-center justify-center rounded-md border border-border bg-bg-elevated", dense ? "size-5" : "size-6")}>314        <ProviderIcon provider={model.provider} size={dense ? 11 : 13} />315      </span>316      <div className="min-w-0 flex-1">317        {progress === 0 ? (318          <p className="inline-flex items-center gap-1.5 text-[12.5px] text-fg-subtle">319            <span className="size-1.5 rounded-full bg-accent animate-pulse-soft" />320            Thinking…321          </p>322        ) : (323          <MockMarkdown text={shown} streaming={!done} className={dense ? "text-[12.5px] sm:text-[12.5px]" : undefined} />324        )}325        {progress > 0 ? <MessageMeta chars={progress} elapsedMs={elapsedMs} ttftMs={ttftMs} done={done} model={model} className="mt-2.5" /> : null}326      </div>327    </div>328  );329}330331export function DesktopChatMock({ prompt, reply, progress, elapsedMs, ttftMs, model, className }: ChatMockProps) {332  const done = progress >= reply.length;333  return (334    <div className={cn("flex min-h-[26rem] bg-bg", className)}>335      <SidebarMock className="hidden md:flex" />336      <div className="flex min-w-0 flex-1 flex-col">337        <div className="flex h-11 items-center gap-2 px-3 hairline-b">338          <ModelPill model={model} />339          <Badge variant="accent" className="hidden sm:inline-flex">340            thinking · 16K341          </Badge>342          <ContextIndicator used="12K" total={model.context} className="ml-auto" />343          <Star className="size-3.5 text-border-strong" aria-hidden />344        </div>345        <div className="min-h-0 flex-1 space-y-5 overflow-hidden px-4 py-4 sm:px-6">346          <div className="flex justify-end">347            <p className="max-w-[85%] rounded-2xl rounded-br-md bg-bg-muted px-3.5 py-2 text-[13px] leading-6 sm:text-[13.5px]">{prompt}</p>348          </div>349          <AssistantTurn reply={reply} progress={progress} elapsedMs={elapsedMs} ttftMs={ttftMs} model={model} />350        </div>351        <div className="p-3 sm:px-6 sm:pb-4">352          <ComposerMock model={model} streaming={!done && progress > 0} compact />353        </div>354      </div>355    </div>356  );357}358359/** Full-screen phone chat with header, messages, composer and bottom nav — the real mobile layout. */360export function MobileChatMock({ prompt, reply, progress, elapsedMs, ttftMs, model }: ChatMockProps) {361  const done = progress >= reply.length;362  return (363    <>364      <MobileHeaderMock center={<ModelPill model={model} size="sm" />} />365      <div className="min-h-0 flex-1 space-y-4 overflow-hidden px-3 py-3 mask-b">366        <div className="flex justify-end">367          <p className="max-w-[88%] rounded-2xl rounded-br-md bg-bg-muted px-3 py-1.5 text-[12.5px] leading-5">{prompt}</p>368        </div>369        <AssistantTurn reply={reply} progress={progress} elapsedMs={elapsedMs} ttftMs={ttftMs} model={model} dense />370      </div>371      <MobileComposerMock streaming={!done && progress > 0} />372      <BottomNavMock active="Chat" />373    </>374  );375}376377/* ------------------------------------------------------------------------------------------------378 * Arena — desktop grid, phone swipeable single response, hero mini card379 * ---------------------------------------------------------------------------------------------- */380export interface ArenaMockProps {381  prompt: string;382  models: MockModel[];383  replies: string[];384  progress: number[];385  elapsedFor: (i: number) => number;386  ttftFor: (i: number) => number;387  fastest: number;388  allDone: boolean;389  /** Index the "user" votes for once everything is done (scripted). */390  winner?: number;391  className?: string;392}393394const VOTES = ["Best", "Most accurate", "Best writing", "Best value"] as const;395396function ArenaColumn({ m, text, progress, elapsedMs, ttftMs, isFastest, allDone, winner, dense }: { m: MockModel; text: string; progress: number; elapsedMs: number; ttftMs: number; isFastest: boolean; allDone: boolean; winner: boolean; dense?: boolean }) {397  const shown = text.slice(0, progress);398  const done = progress >= text.length;399  return (400    <div className="flex h-full min-w-0 flex-col p-3 sm:p-4">401      <div className="flex items-center gap-1.5 text-[12.5px] font-medium">402        <ProviderIcon provider={m.provider} size={13} />403        <span className="truncate">{m.name}</span>404        {done && isFastest ? (405          <Badge variant="success" className="ml-auto">406            <Zap /> fastest407          </Badge>408        ) : !done && progress > 0 ? (409          <span className="ml-auto size-1.5 rounded-full bg-accent animate-pulse-soft" aria-label="streaming" />410        ) : !done ? (411          <span className="ml-auto font-mono text-[10px] text-fg-subtle">queued</span>412        ) : null}413      </div>414      <div className="mt-2.5 min-h-0 flex-1">415        {progress === 0 ? <p className="text-[12px] text-fg-subtle animate-pulse-soft">Waiting for first token…</p> : <MockMarkdown text={shown} streaming={!done} className={dense ? "text-[12.5px] sm:text-[12.5px]" : undefined} />}416      </div>417      <div className="mt-3 border-t border-hairline pt-2.5">418        {progress > 0 ? <MessageMeta chars={progress} elapsedMs={elapsedMs} ttftMs={ttftMs} done={done} model={m} /> : <span className="font-mono text-[10.5px] text-fg-subtle">—</span>}419        <div className={cn("mt-2.5 flex flex-wrap gap-1 transition-opacity duration-300", allDone ? "opacity-100" : "pointer-events-none opacity-0")} aria-hidden={!allDone}>420          {VOTES.map((v, i) => (421            <span key={v} className={cn("inline-flex h-6 items-center gap-1 rounded-full border px-2 text-[10.5px] font-medium", winner && i === 0 ? "border-fg bg-fg text-bg" : "border-border text-fg-muted")}>422              {winner && i === 0 ? <Check className="size-3" /> : null}423              {v}424            </span>425          ))}426        </div>427      </div>428    </div>429  );430}431432export function DesktopArenaMock({ prompt, models, replies, progress, elapsedFor, ttftFor, fastest, allDone, winner = 0, className }: ArenaMockProps) {433  return (434    <div className={cn("flex min-h-[26rem] bg-bg", className)}>435      <SidebarMock active="Arena" activeConversation={null} className="hidden lg:flex" />436      <div className="flex min-w-0 flex-1 flex-col">437        <div className="flex h-11 items-center gap-2 px-3 text-[12.5px] hairline-b">438          <span className="font-semibold">Arena</span>439          <Badge>{models.length} models</Badge>440          <Badge variant="outline" className="hidden sm:inline-flex">441            Blind: off442          </Badge>443          {allDone ? (444            <Badge variant="accent" className="ml-auto animate-fade-in">445              <Trophy /> Winner: {models[winner].name}446            </Badge>447          ) : (448            <span className="ml-auto font-mono text-[10.5px] text-fg-subtle">streaming…</span>449          )}450        </div>451        <div className="px-3 py-2.5 text-[12.5px] hairline-b sm:px-4">452          <span className="text-fg-subtle">Prompt · </span>453          {prompt}454        </div>455        <div className={cn("grid min-h-0 flex-1 divide-y divide-border md:divide-x md:divide-y-0", models.length === 2 ? "md:grid-cols-2" : models.length === 4 ? "md:grid-cols-2 lg:grid-cols-4" : "md:grid-cols-3")}>456          {models.map((m, i) => (457            <ArenaColumn key={m.key} m={m} text={replies[i]} progress={progress[i]} elapsedMs={elapsedFor(i)} ttftMs={ttftFor(i)} isFastest={i === fastest} allDone={allDone} winner={i === winner} />458          ))}459        </div>460      </div>461    </div>462  );463}464465/** Phone Arena: sticky model tabs + a `.snap-row` carousel, one response at a time (as in the real app). */466export function MobileArenaMock({ prompt, models, replies, progress, elapsedFor, ttftFor, fastest, allDone, winner = 0 }: ArenaMockProps) {467  const ref = React.useRef<HTMLDivElement>(null);468  const [index, setIndex] = React.useState(0);469  React.useEffect(() => {470    const el = ref.current;471    if (!el) return;472    let raf = 0;473    const onScroll = () => {474      cancelAnimationFrame(raf);475      raf = requestAnimationFrame(() => setIndex(Math.max(0, Math.min(models.length - 1, Math.round(el.scrollLeft / (el.clientWidth || 1))))));476    };477    el.addEventListener("scroll", onScroll, { passive: true });478    return () => {479      el.removeEventListener("scroll", onScroll);480      cancelAnimationFrame(raf);481    };482  }, [models.length]);483  const go = (i: number) => ref.current?.scrollTo({ left: i * (ref.current.clientWidth || 0), behavior: "smooth" });484485  return (486    <>487      <MobileHeaderMock title="Arena" right={<span className="px-2 text-[12px] font-medium text-accent">New</span>} />488      <div className="px-3 py-2 text-[12px] leading-5 hairline-b">489        <span className="text-fg-subtle">Prompt · </span>490        {prompt}491      </div>492      <div className="flex gap-1 overflow-x-auto px-2 py-1.5 scrollbar-none hairline-b" role="tablist" aria-label="Arena responses">493        {models.map((m, i) => (494          <button key={m.key} type="button" role="tab" aria-selected={index === i} onClick={() => go(i)} className={cn("inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] font-medium transition-colors", index === i ? "bg-fg text-bg" : "bg-bg-muted text-fg-muted")}>495            <ProviderIcon provider={m.provider} size={11} />496            {m.name}497            {progress[i] > 0 && progress[i] < replies[i].length ? <span className="size-1.5 rounded-full bg-accent animate-pulse-soft" /> : null}498          </button>499        ))}500      </div>501      <div ref={ref} className="snap-row min-h-0 flex-1 touch-pan-x" data-no-edge-swipe>502        {models.map((m, i) => (503          <div key={m.key} className="h-full overflow-hidden">504            <ArenaColumn m={m} text={replies[i]} progress={progress[i]} elapsedMs={elapsedFor(i)} ttftMs={ttftFor(i)} isFastest={i === fastest} allDone={allDone} winner={i === winner} dense />505          </div>506        ))}507      </div>508      <div className="flex justify-center gap-1 pb-1.5" aria-hidden>509        {models.map((m, i) => (510          <span key={m.key} className={cn("size-1.5 rounded-full transition-colors", i === index ? "bg-fg" : "bg-border-strong")} />511        ))}512      </div>513      <BottomNavMock active="Arena" />514    </>515  );516}517518/** Small "Arena · 3 models" card layered over the hero window. */519export function ArenaMiniCard({ models, replies, progress, elapsedFor, fastest, allDone, className }: Omit<ArenaMockProps, "prompt" | "ttftFor">) {520  return (521    <div className={cn("w-[17rem] rounded-xl border border-border bg-bg-elevated p-3 shadow-lg", className)} aria-hidden>522      <div className="flex items-center gap-1.5 text-[12px] font-semibold">523        <Swords className="size-3.5 text-accent" /> Arena524        <span className="ml-auto font-mono text-[10px] font-normal text-fg-subtle">{allDone ? "done" : "streaming"}</span>525      </div>526      <ul className="mt-2.5 space-y-2">527        {models.map((m, i) => {528          const pct = Math.min(100, Math.round((progress[i] / replies[i].length) * 100));529          const tps = progress[i] > 0 ? Math.round(approxTokens(progress[i]) / Math.max(0.1, elapsedFor(i) / 1000)) : 0;530          return (531            <li key={m.key} className="text-[11px]">532              <div className="flex items-center gap-1.5">533                <ProviderIcon provider={m.provider} size={11} />534                <span className="truncate font-medium">{m.name}</span>535                {pct >= 100 && i === fastest ? <Zap className="size-3 text-success" /> : null}536                <span className="ml-auto font-mono tabular-nums text-fg-subtle">{tps ? `${tps} tok/s` : "—"}</span>537              </div>538              <div className="mt-1 h-1 overflow-hidden rounded-full bg-bg-muted">539                <div className="h-full rounded-full bg-accent transition-[width] duration-150" style={{ width: `${pct}%` }} />540              </div>541            </li>542          );543        })}544      </ul>545    </div>546  );547}548549/* ------------------------------------------------------------------------------------------------550 * Models catalog — desktop table, phone stacked rows551 * ---------------------------------------------------------------------------------------------- */552const MODEL_CHIPS = ["All", "Favorites", "Reasoning", "Vision", "Cheap", "Long context", "New"] as const;553const FAVORITES = new Set(["anthropic/claude-opus-5", "openai/gpt-5.5", "gemini/gemini-3.8-flash"]);554const TAG_LABEL: Record<NonNullable<MockModel["tags"]>[number], { label: string; variant: "accent" | "success" | "info" | "warning" | "default" }> = {555  new: { label: "New", variant: "accent" },556  fast: { label: "Fast", variant: "success" },557  cheap: { label: "Cheap", variant: "success" },558  reasoning: { label: "Reasoning", variant: "info" },559  vision: { label: "Vision", variant: "default" },560  coding: { label: "Coding", variant: "default" },561  long: { label: "Long context", variant: "warning" },562};563564function ChipsMock({ active = "All", className }: { active?: (typeof MODEL_CHIPS)[number]; className?: string }) {565  return (566    <div className={cn("flex gap-1.5 overflow-x-auto scrollbar-none", className)} aria-hidden>567      {MODEL_CHIPS.map((c) => (568        <span key={c} className={cn("inline-flex h-6.5 shrink-0 items-center rounded-full border px-2.5 text-[11px] font-medium", c === active ? "border-fg bg-fg text-bg" : "border-border bg-bg-elevated text-fg-muted")}>569          {c}570        </span>571      ))}572    </div>573  );574}575576export function DesktopModelsMock({ className }: { className?: string }) {577  return (578    <div className={cn("flex min-h-[26rem] bg-bg", className)}>579      <SidebarMock active="Models" activeConversation={null} className="hidden lg:flex" />580      <div className="flex min-w-0 flex-1 flex-col">581        <div className="flex h-11 items-center gap-3 px-3 hairline-b">582          <span className="text-[12.5px] font-semibold">Models</span>583          <span className="font-mono text-[10.5px] text-fg-subtle">{MOCK_MODELS.length * 6 - 2} models · 9 providers</span>584          <span className="ml-auto inline-flex h-7 w-52 items-center gap-2 rounded-md border border-border bg-bg-elevated px-2 text-[12px] text-fg-subtle">585            <Search className="size-3.5" /> cheap vision model586            <kbd className="kbd ml-auto">⌘/</kbd>587          </span>588        </div>589        <div className="px-3 py-2 hairline-b">590          <ChipsMock />591        </div>592        <table className="w-full text-[12.5px]">593          <thead>594            <tr className="text-left font-mono text-[10.5px] font-normal uppercase tracking-[0.08em] text-fg-subtle">595              <th className="px-3 py-2 font-normal">Model</th>596              <th className="hidden px-3 py-2 font-normal sm:table-cell">Context</th>597              <th className="px-3 py-2 text-right font-normal">$ / M in · out</th>598              <th className="hidden px-3 py-2 font-normal md:table-cell">Badges</th>599              <th className="w-10 px-3 py-2" />600            </tr>601          </thead>602          <tbody>603            {MOCK_MODELS.slice(0, 7).map((m, i) => (604              <tr key={m.key} className={cn("hairline-t", i === 2 && "bg-accent-soft/40")}>605                <td className="px-3 py-2">606                  <div className="flex items-center gap-2">607                    <ProviderIcon provider={m.provider} size={14} />608                    <div className="min-w-0">609                      <p className="truncate font-medium">{m.name}</p>610                      <p className="truncate font-mono text-[10.5px] text-fg-subtle">{m.key}</p>611                    </div>612                  </div>613                </td>614                <td className="hidden px-3 py-2 font-mono text-[11.5px] tabular-nums text-fg-muted sm:table-cell">{m.context}</td>615                <td className="px-3 py-2 text-right font-mono text-[11.5px] tabular-nums text-fg-muted">616                  {m.input} · {m.output}617                </td>618                <td className="hidden px-3 py-2 md:table-cell">619                  <div className="flex gap-1">620                    {(m.tags ?? []).slice(0, 2).map((t) => (621                      <Badge key={t} variant={TAG_LABEL[t].variant}>622                        {TAG_LABEL[t].label}623                      </Badge>624                    ))}625                  </div>626                </td>627                <td className="px-3 py-2 text-right">628                  <Star className={cn("inline size-3.5", FAVORITES.has(m.key) ? "fill-warning text-warning" : "text-border-strong")} aria-hidden />629                </td>630              </tr>631            ))}632          </tbody>633        </table>634        <div className="mt-auto flex items-center justify-between px-3 py-2 font-mono text-[10.5px] text-fg-subtle hairline-t">635          <span>Synced 4 min ago</span>636          <span>2 selected → Compare</span>637        </div>638      </div>639    </div>640  );641}642643export function MobileModelsMock() {644  return (645    <>646      <MobileHeaderMock title="Models" right={<span className="flex size-8 items-center justify-center rounded-md text-fg-muted"><Search className="size-[17px]" /></span>} />647      <div className="px-2.5 pt-2">648        <ChipsMock active="Favorites" />649      </div>650      <ul className="min-h-0 flex-1 overflow-hidden px-2.5 pt-1 mask-b">651        {MOCK_MODELS.slice(0, 7).map((m) => (652          <li key={m.key} className="flex items-center gap-2.5 py-2.5 hairline-b">653            <span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-bg-subtle">654              <ProviderIcon provider={m.provider} size={15} />655            </span>656            <div className="min-w-0 flex-1">657              <p className="truncate text-[13px] font-medium">{m.name}</p>658              <p className="truncate font-mono text-[10.5px] text-fg-subtle">659                {m.context} ctx · {m.input} / {m.output}660              </p>661            </div>662            <Star className={cn("size-4 shrink-0", FAVORITES.has(m.key) ? "fill-warning text-warning" : "text-border-strong")} aria-hidden />663            <ChevronRight className="size-4 shrink-0 text-fg-subtle" aria-hidden />664          </li>665        ))}666      </ul>667      <BottomNavMock active="Models" />668    </>669  );670}671672/* ------------------------------------------------------------------------------------------------673 * Usage analytics — desktop dashboard, phone stacked tiles674 * ---------------------------------------------------------------------------------------------- */675function Bars({ values, className, label }: { values: number[]; className?: string; label: string }) {676  const max = Math.max(...values);677  return (678    <div className={cn("flex items-end gap-[3px] border-b border-hairline", className)} role="img" aria-label={label}>679      {values.map((v, i) => (680        <div key={i} title={`${USAGE_DAY_LABELS[i]} · ${v}`} className="flex-1 rounded-t-[3px] bg-accent/80" style={{ height: `${Math.max(6, (v / max) * 100)}%` }} />681      ))}682    </div>683  );684}685686function ProviderCostRows({ dense }: { dense?: boolean }) {687  const total = USAGE_BY_PROVIDER.reduce((a, b) => a + b.cost, 0);688  return (689    <ul className={cn("space-y-2", dense && "space-y-1.5")}>690      {USAGE_BY_PROVIDER.map((row) => (691        <li key={row.provider} className={cn("grid items-center gap-2 text-[12px]", dense ? "grid-cols-[5.5rem_1fr_3rem]" : "grid-cols-[7rem_1fr_3.5rem]")}>692          <span className="inline-flex items-center gap-1.5 truncate">693            <ProviderIcon provider={row.provider} size={12} />694            {PROVIDER_LABEL[row.provider]}695          </span>696          <span className="h-1.5 overflow-hidden rounded-full bg-bg-muted">697            <span className="block h-full rounded-full" style={{ width: `${(row.cost / total) * 100}%`, background: `var(--p-${row.provider})` }} />698          </span>699          <span className="text-right font-mono tabular-nums text-fg-muted">${row.cost.toFixed(2)}</span>700        </li>701      ))}702    </ul>703  );704}705706function SavingsCard({ dense }: { dense?: boolean }) {707  return (708    <div className={cn("rounded-xl bg-accent-soft/60 p-3", dense && "p-2.5")}>709      <p className="inline-flex items-center gap-1.5 text-[11.5px] font-semibold text-accent">710        <Sparkles className="size-3.5" /> Savings opportunity711      </p>712      <p className="mt-1 text-[12px] font-medium">{USAGE_SAVINGS.title}</p>713      <p className="mt-0.5 text-[11.5px] leading-5 text-fg-muted">{USAGE_SAVINGS.body}</p>714    </div>715  );716}717718export function DesktopUsageMock({ className }: { className?: string }) {719  return (720    <div className={cn("flex min-h-[26rem] bg-bg", className)}>721      <SidebarMock active="Usage" activeConversation={null} className="hidden lg:flex" />722      <div className="flex min-w-0 flex-1 flex-col">723        <div className="flex h-11 items-center gap-2 px-3 hairline-b">724          <span className="text-[12.5px] font-semibold">Usage</span>725          <div className="ml-auto inline-flex h-7 items-center gap-0.5 rounded-lg bg-bg-muted p-0.5 text-[11.5px]" aria-hidden>726            {["Today", "7d", "30d", "90d"].map((r) => (727              <span key={r} className={cn("rounded-md px-2 py-0.5", r === "30d" ? "bg-bg-elevated font-medium shadow-xs" : "text-fg-muted")}>728                {r}729              </span>730            ))}731          </div>732        </div>733        <div className="grid grid-cols-2 gap-px bg-hairline sm:grid-cols-4">734          {USAGE_KPIS.map((k) => (735            <div key={k.label} className="bg-bg px-3.5 py-3">736              <p className="text-[11px] font-medium text-fg-muted">{k.label}</p>737              <p className="mt-0.5 text-lg font-semibold tabular-nums tracking-tight">{k.value}</p>738              <p className="text-[10.5px] text-fg-subtle">{k.hint}</p>739            </div>740          ))}741        </div>742        <div className="grid gap-4 p-3.5 hairline-t md:grid-cols-[1.4fr_1fr]">743          <div>744            <div className="flex items-baseline justify-between">745              <p className="text-[12px] font-medium">Cost per day</p>746              <p className="font-mono text-[10.5px] text-fg-subtle">peak ${Math.max(...USAGE_COST_SERIES).toFixed(2)}</p>747            </div>748            <Bars values={USAGE_COST_SERIES} className="mt-3 h-24" label="Cost per day over the last 14 days" />749            <div className="mt-1.5 flex justify-between font-mono text-[10px] text-fg-subtle">750              <span>{USAGE_DAY_LABELS[0]}</span>751              <span>{USAGE_DAY_LABELS[USAGE_DAY_LABELS.length - 1]}</span>752            </div>753          </div>754          <div className="space-y-3">755            <p className="text-[12px] font-medium">Cost by provider</p>756            <ProviderCostRows />757            <SavingsCard dense />758          </div>759        </div>760      </div>761    </div>762  );763}764765export function MobileUsageMock() {766  return (767    <>768      <MobileHeaderMock title="Usage" right={<span className="px-2 font-mono text-[11px] text-fg-muted">30d</span>} />769      <div className="min-h-0 flex-1 space-y-3 overflow-hidden px-3 pt-3 mask-b">770        <div className="grid grid-cols-2 gap-2">771          {USAGE_KPIS.map((k) => (772            <div key={k.label} className="panel px-3 py-2.5">773              <p className="text-[10.5px] font-medium text-fg-muted">{k.label}</p>774              <p className="mt-0.5 text-[17px] font-semibold tabular-nums tracking-tight">{k.value}</p>775            </div>776          ))}777        </div>778        <div className="panel p-3">779          <div className="flex items-baseline justify-between">780            <p className="text-[12px] font-medium">Requests per day</p>781            <p className="font-mono text-[10px] text-fg-subtle">peak {Math.max(...USAGE_SERIES)}</p>782          </div>783          <Bars values={USAGE_SERIES} className="mt-2.5 h-16" label="Requests per day over the last 14 days" />784        </div>785        <div className="panel p-3">786          <p className="mb-2 text-[12px] font-medium">Cost by provider</p>787          <ProviderCostRows dense />788        </div>789        <SavingsCard dense />790      </div>791      <BottomNavMock active="Usage" />792    </>793  );794}795796/* ------------------------------------------------------------------------------------------------797 * Scoreboard (feature visual)798 * ---------------------------------------------------------------------------------------------- */799export function ScoreboardMock({ className }: { className?: string }) {800  return (801    <div className={cn("panel p-4", className)}>802      <div className="flex items-center gap-2 text-[12.5px] font-semibold">803        <Trophy className="size-4 text-warning" /> Your scoreboard804        <span className="ml-auto font-mono text-[10.5px] font-normal text-fg-subtle">Coding · last 30 d</span>805      </div>806      <ul className="mt-3 space-y-2.5">807        {SCOREBOARD.map((r, i) => {808          const pct = Math.round((r.wins / r.total) * 100);809          return (810            <li key={r.model.key} className="grid grid-cols-[1.25rem_1fr_3rem] items-center gap-2 text-[12.5px]">811              <span className="font-mono text-[11px] text-fg-subtle">{i + 1}</span>812              <div className="min-w-0">813                <div className="flex items-center gap-1.5">814                  <ProviderIcon provider={r.model.provider} size={12} />815                  <span className="truncate font-medium">{r.model.name}</span>816                  <span className="ml-auto font-mono text-[10.5px] text-fg-subtle">817                    {r.wins}/{r.total}818                  </span>819                </div>820                <div className="mt-1 h-1.5 overflow-hidden rounded-full bg-bg-muted">821                  <div className="h-full rounded-full" style={{ width: `${pct}%`, background: `var(--p-${r.model.provider})` }} />822                </div>823              </div>824              <span className="text-right font-mono text-[11.5px] tabular-nums">{pct}%</span>825            </li>826          );827        })}828      </ul>829    </div>830  );831}832