"use client"; import * as React from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useVirtualizer } from "@tanstack/react-virtual"; import { Check, ChevronDown, ChevronRight, GitCompareArrows, Info, KeyRound, MoreHorizontal, Search, Sparkles, Star, Tag, X } from "lucide-react"; import { AUTO_MODEL_KEY, useApp } from "@/components/app/store"; import type { PolyModel, ProviderId } from "@/lib/client/types"; import { PROVIDERS, PROVIDER_ORDER } from "@/lib/client/providers"; import { ProviderIcon } from "@/components/brand/provider-icon"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Kbd } from "@/components/ui/misc"; import { Segmented } from "@/components/ui/segmented"; import { ActionSheet, ResponsiveDialog, type ActionSheetItem } from "@/components/ui/sheet"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { PromptDialog } from "@/components/common/prompt-dialog"; import { ModelProfile } from "@/components/models/model-profile"; import { CapabilityGlyphs, ModelBadges, StatusBadge, useBadgeContext } from "./model-badges"; import { useDebounced, useIsMobile, useLocalStorage, useLongPress } from "@/lib/client/hooks"; import { ROUTER_MODES, explainRoute, type RouterMode } from "@/lib/client/router"; import { buildSelectorSections, recommendedModels, type Section } from "@/lib/models/sections"; import { parseSearchQuery, searchModels, isEmptyIntent } from "@/lib/models/search"; import { sortWeightOf, type BadgeContext } from "@/lib/models/badges"; import { formatPrice } from "@/lib/models/format"; import { cn, formatTokens } from "@/lib/utils"; export const ROUTER_MODE_KEY = "polyllm:router-mode"; export interface ModelSelectorProps { /** Register the global ⌘/ toggle (disable on hidden/secondary instances). */ hotkey?: boolean; value: string | null; onChange?: (key: string) => void; className?: string; size?: "sm" | "md"; /** List models whose provider has no key (rendered as Unavailable). */ allowDisconnected?: boolean; multiple?: boolean; selected?: Set; onToggle?: (key: string) => void; buttonLabel?: string; /** Show the Smart Router "Auto" entry (selects `AUTO_MODEL_KEY`). */ showAuto?: boolean; /** Custom trigger element (wrapped; click opens the picker). */ trigger?: React.ReactNode; /** Current composer draft — powers the "Recommended" section. */ draft?: string; } type Item = | { type: "auto" } | { type: "header"; key: string; label: string; hint?: string; total: number; shown: number; expandable: boolean; expanded: boolean } | { type: "row"; model: PolyModel; section: string; why?: string[] } | { type: "provider"; provider: ProviderId; count: number }; const ROW_H = 56; export function ModelSelector({ value, onChange, className, size = "md", allowDisconnected = false, multiple, selected, onToggle, buttonLabel, showAuto = false, trigger, draft, hotkey = true }: ModelSelectorProps) { const { models, modelsByKey, favorites, recents, labels, connectedProviders, toggleFavorite, setLabel, loadingModels } = useApp(); const isMobile = useIsMobile(); const router = useRouter(); const ctx = useBadgeContext(); const [open, setOpen] = React.useState(false); const [q, setQ] = React.useState(""); const dq = useDebounced(q, 60); const [mode, setMode] = useLocalStorage(ROUTER_MODE_KEY, "balanced"); const [expanded, setExpanded] = React.useState>(() => new Set()); const [active, setActive] = React.useState(0); const [menuModel, setMenuModel] = React.useState(null); const [labelModel, setLabelModel] = React.useState(null); const [profileModel, setProfileModel] = React.useState(null); const inputRef = React.useRef(null); const scrollRef = React.useRef(null); // The list mounts only while the dialog is open: expose the element through state so the virtualizer re-subscribes. const [scrollEl, setScrollEl] = React.useState(null); const setScrollRef = React.useCallback((el: HTMLDivElement | null) => { scrollRef.current = el; setScrollEl(el); }, []); const current = value && value !== AUTO_MODEL_KEY ? modelsByKey.get(value) : undefined; const isAuto = value === AUTO_MODEL_KEY; React.useEffect(() => { if (!hotkey) return; const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "/") { e.preventDefault(); setOpen((o) => !o); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [hotkey]); React.useEffect(() => { if (open) { if (!isMobile) setTimeout(() => inputRef.current?.focus(), 40); } else { setQ(""); setExpanded(new Set()); } }, [open, isMobile]); const usable = React.useMemo(() => models.filter((m) => (allowDisconnected || connectedProviders.has(m.provider)) && m.status !== "deprecated"), [models, connectedProviders, allowDisconnected]); const intent = React.useMemo(() => parseSearchQuery(dq), [dq]); const searching = dq.trim().length > 0 && !isEmptyIntent(intent); const results = React.useMemo(() => (searching ? searchModels(usable, intent, { ctx, favorites, recents, labels, providerNames: Object.fromEntries(PROVIDER_ORDER.map((p) => [p, PROVIDERS[p].name])) }) : null), [searching, usable, intent, ctx, favorites, recents, labels]); const sections = React.useMemo(() => (searching ? [] : buildSelectorSections(usable, { favorites, recents, ctx, mode, draft, perSection: 5 })), [searching, usable, favorites, recents, ctx, mode, draft]); const autoPick = React.useMemo(() => (showAuto && !multiple ? recommendedModels(usable, { favorites, mode, draft, limit: 1 }) : null), [showAuto, multiple, usable, favorites, mode, draft]); const items = React.useMemo(() => { const out: Item[] = []; if (searching && results) { out.push({ type: "header", key: "results", label: results.length ? `${results.length} result${results.length === 1 ? "" : "s"}` : "No matches", total: results.length, shown: results.length, expandable: false, expanded: true }); for (const r of results) out.push({ type: "row", model: r.model, section: "results", why: r.matches }); return out; } if (showAuto && !multiple) out.push({ type: "auto" }); for (const s of sections) { const isOpen = expanded.has(s.key); const list = isOpen ? s.all : s.items; out.push({ type: "header", key: s.key, label: s.label, hint: s.hint, total: s.total, shown: list.length, expandable: s.total > s.items.length, expanded: isOpen }); for (const m of list) out.push({ type: "row", model: m, section: s.key }); } // Everything, grouped by provider const byProvider = PROVIDER_ORDER.map((p) => ({ p, list: usable.filter((m) => m.provider === p).sort((a, b) => sortWeightOf(b) - sortWeightOf(a) || a.displayName.localeCompare(b.displayName)) })).filter((g) => g.list.length); if (byProvider.length) { out.push({ type: "header", key: "all", label: "All models", hint: `${usable.length} across ${byProvider.length} provider${byProvider.length === 1 ? "" : "s"}`, total: usable.length, shown: usable.length, expandable: false, expanded: true }); for (const g of byProvider) { out.push({ type: "provider", provider: g.p, count: g.list.length }); for (const m of g.list) out.push({ type: "row", model: m, section: `all:${g.p}` }); } } return out; }, [searching, results, showAuto, multiple, sections, expanded, usable]); const selectable = React.useMemo(() => items.map((it, i) => (it.type === "row" || it.type === "auto" ? i : -1)).filter((i) => i >= 0), [items]); // Reset the keyboard cursor when the list changes. React.useEffect(() => { setActive(selectable[0] ?? 0); }, [selectable]); const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => scrollEl, estimateSize: (i) => { const it = items[i]; return it.type === "row" ? ROW_H : it.type === "auto" ? 168 : it.type === "provider" ? 32 : 40; }, overscan: 10, getItemKey: (i) => { const it = items[i]; return it.type === "row" ? `${it.section}:${it.model.key}` : it.type === "header" ? `h:${it.key}` : it.type === "provider" ? `p:${it.provider}` : "auto"; }, }); const pick = React.useCallback( (m: PolyModel) => { if (!allowDisconnected && !connectedProviders.has(m.provider)) return; if (multiple && onToggle) { onToggle(m.key); return; } onChange?.(m.key); setOpen(false); }, [allowDisconnected, connectedProviders, multiple, onToggle, onChange], ); const pickAuto = () => { onChange?.(AUTO_MODEL_KEY); setOpen(false); }; const moveActive = (dir: 1 | -1) => { if (!selectable.length) return; const pos = selectable.indexOf(active); const next = selectable[Math.max(0, Math.min(selectable.length - 1, (pos < 0 ? (dir > 0 ? -1 : 0) : pos) + dir))]; setActive(next); virtualizer.scrollToIndex(next, { align: "auto" }); }; const onKeyNav = (e: React.KeyboardEvent) => { if (e.key === "ArrowDown") { e.preventDefault(); moveActive(1); } else if (e.key === "ArrowUp") { e.preventDefault(); moveActive(-1); } else if (e.key === "Enter") { const it = items[active]; if (!it) return; e.preventDefault(); if (it.type === "row") pick(it.model); else if (it.type === "auto") pickAuto(); } }; const openCompare = (m: PolyModel) => { const keys = [...new Set([current?.key, m.key].filter((k): k is string => Boolean(k)))]; setOpen(false); router.push(`/app/models/compare?m=${encodeURIComponent(keys.join(","))}`); }; const menuItems = (m: PolyModel): ActionSheetItem[] => { const fav = favorites.has(m.key); const items: ActionSheetItem[] = [ { key: "fav", label: fav ? "Remove from favorites" : "Add to favorites", icon: , onSelect: () => void toggleFavorite(m.key) }, { key: "label", label: labels[m.key] ? "Change custom label" : "Set custom label", icon: , hint: labels[m.key], onSelect: () => setLabelModel(m) }, ]; if (labels[m.key]) items.push({ key: "unlabel", label: "Remove custom label", icon: , onSelect: () => void setLabel(m.key, null) }); items.push({ key: "profile", label: "View profile", icon: , onSelect: () => setProfileModel(m) }, { key: "compare", label: current && current.key !== m.key ? `Compare with ${current.displayName}` : "Compare", icon: , onSelect: () => openCompare(m) }); return items; }; const toggleExpanded = (key: string) => setExpanded((prev) => { const n = new Set(prev); if (n.has(key)) n.delete(key); else n.add(key); return n; }); const noProviders = connectedProviders.size === 0 && !loadingModels; const triggerLabel = buttonLabel ? ( {buttonLabel} ) : isAuto ? ( <> Auto · {ROUTER_MODES.find((r) => r.value === mode)?.label} ) : current ? ( <> {labels[current.key] ?? current.displayName} {labels[current.key] ? {current.displayName} : null} ) : ( {loadingModels ? "Loading models…" : noProviders ? "Connect a provider" : "Choose a model"} ); const header = (
setQ(e.target.value)} onKeyDown={onKeyNav} placeholder={isMobile ? "Search models…" : "Search — try “cheap vision”, “1M context”, “under $1/M”, “fastest gemini”"} className="h-12 min-w-0 flex-1 bg-transparent text-[16px] outline-none placeholder:text-fg-subtle sm:text-[15px]" aria-label="Search models" role="combobox" aria-expanded aria-controls="model-selector-list" aria-activedescendant={items[active] ? `model-opt-${active}` : undefined} autoComplete="off" enterKeyHint="done" /> {q ? ( ) : ( esc )}
{searching && intent.chips.length ? (
Understood: {intent.chips.map((c) => ( {c} ))}
) : null}
); const renderItem = (it: Item, index: number) => { switch (it.type) { case "auto": return ; case "header": return (
{it.label} {it.hint ? {it.hint} : null}
{it.expandable ? ( ) : null}
); case "provider": return (
{PROVIDERS[it.provider].name} · {it.count}
); case "row": { const m = it.model; return ( pick(m)} onFav={() => void toggleFavorite(m.key)} onMenu={() => setMenuModel(m)} onHover={() => setActive(index)} menuItems={() => menuItems(m)} /> ); } } }; return ( <> {trigger ? ( setOpen(true)}> {trigger} ) : ( )} {selected?.size ?? 0} selected (max 4) ) : isMobile ? undefined : (
↑↓ navigate ↵ select ⌘/ toggle {usable.length} models · {connectedProviders.size} provider{connectedProviders.size === 1 ? "" : "s"}
) } > {noProviders ? (

No provider connected yet

Add an API key from any supported provider to unlock its models. Keys are encrypted and never leave the server.

) : (
{ if ((scrollRef.current?.scrollTop ?? 0) > 0) e.stopPropagation(); }} className="min-h-0 flex-1 overflow-y-auto pb-[max(12px,var(--sab))] scrollbar-thin contain-scroll" style={{ overflowAnchor: "none" }}> {searching && results && results.length === 0 ? (

No models match “{q}”

Try a capability (“vision”, “reasoning”), a size (“1M context”), a price (“under $1/M”) or a provider (“gemini”).

) : (
{virtualizer.getVirtualItems().map((v) => (
{renderItem(items[v.index], v.index)}
))}
)}
)}
(!v ? setMenuModel(null) : null)} title={menuModel?.displayName} description={menuModel ? `${PROVIDERS[menuModel.provider].name} · ${menuModel.id}` : undefined} items={menuModel ? menuItems(menuModel) : []} /> (!v ? setLabelModel(null) : null)} title="Custom label" description={labelModel ? `Shown next to ${labelModel.displayName} everywhere in PolyLLM.` : undefined} label="Label" placeholder="My research model" defaultValue={labelModel ? labels[labelModel.key] ?? "" : ""} maxLength={60} onSubmit={(v) => (labelModel ? setLabel(labelModel.key, v) : undefined)} /> (!v ? setProfileModel(null) : null)} compareWith={current?.key ?? null} onUse={(m) => pick(m)} /> ); } /* ------------------------------------------------------------------------------------------------ */ function AutoCard({ selected, active, mode, onMode, onPick, pick, id }: { selected: boolean; active: boolean; mode: RouterMode; onMode: (m: RouterMode) => void; onPick: () => void; pick: ReturnType | null; id: string }) { const top = pick?.route.recommended ?? null; return (
({ value: r.value, label: r.label }))} className="mt-3 w-full" />

{top ? ( <> Right now → {top.model.displayName} · {explainRoute(top)} ) : ( "Connect a provider to enable routing." )}

); } interface RowProps { id: string; model: PolyModel; ctx: BadgeContext; label?: string; fav: boolean; connected: boolean; selected: boolean; active: boolean; multiple: boolean; isMobile: boolean; why?: string[]; onPick: () => void; onFav: () => void; onMenu: () => void; onHover: () => void; menuItems: () => ActionSheetItem[]; } const stop = (e: React.SyntheticEvent) => e.stopPropagation(); const SelectorRow = React.memo(function SelectorRow({ id, model: m, ctx, label, fav, connected, selected, active, multiple, isMobile, why, onPick, onFav, onMenu, onHover, menuItems }: RowProps) { const press = useLongPress({ onLongPress: onMenu, onClick: onPick, disabled: !isMobile }); const disabled = !connected; return (
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onPick(); } }} {...press} className={cn("group mx-2 flex h-14 select-none items-center gap-2.5 rounded-lg px-2 transition-colors", active && !selected && "bg-bg-subtle", selected && "bg-accent-soft/50", disabled ? "cursor-default opacity-60" : "cursor-pointer")} >
{m.displayName} {label ? {label} : null}
{m.limits?.contextTokens ? {formatTokens(m.limits.contextTokens)} ctx : null} {m.pricing && (m.pricing.inputPerMillion !== undefined || m.pricing.outputPerMillion !== undefined) ? ( · {formatPrice(m.pricing.inputPerMillion)} / {formatPrice(m.pricing.outputPerMillion)} ) : null} {why?.length ? · {why.join(", ")} : null}
{multiple && selected ? : null} {isMobile ? ( ) : ( {menuItems().map((it) => ( {it.key === "profile" ? : null} {it.icon} {it.label} ))} )}
); });