TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import Link from "next/link";4import { useRouter } from "next/navigation";5import { useVirtualizer } from "@tanstack/react-virtual";6import { Check, ChevronDown, ChevronRight, GitCompareArrows, Info, KeyRound, MoreHorizontal, Search, Sparkles, Star, Tag, X } from "lucide-react";7import { AUTO_MODEL_KEY, useApp } from "@/components/app/store";8import type { PolyModel, ProviderId } from "@/lib/client/types";9import { PROVIDERS, PROVIDER_ORDER } from "@/lib/client/providers";10import { ProviderIcon } from "@/components/brand/provider-icon";11import { Button } from "@/components/ui/button";12import { Badge } from "@/components/ui/badge";13import { Kbd } from "@/components/ui/misc";14import { Segmented } from "@/components/ui/segmented";15import { ActionSheet, ResponsiveDialog, type ActionSheetItem } from "@/components/ui/sheet";16import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";17import { PromptDialog } from "@/components/common/prompt-dialog";18import { ModelProfile } from "@/components/models/model-profile";19import { CapabilityGlyphs, ModelBadges, StatusBadge, useBadgeContext } from "./model-badges";20import { useDebounced, useIsMobile, useLocalStorage, useLongPress } from "@/lib/client/hooks";21import { ROUTER_MODES, explainRoute, type RouterMode } from "@/lib/client/router";22import { buildSelectorSections, recommendedModels, type Section } from "@/lib/models/sections";23import { parseSearchQuery, searchModels, isEmptyIntent } from "@/lib/models/search";24import { sortWeightOf, type BadgeContext } from "@/lib/models/badges";25import { formatPrice } from "@/lib/models/format";26import { cn, formatTokens } from "@/lib/utils";2728export const ROUTER_MODE_KEY = "polyllm:router-mode";2930export interface ModelSelectorProps {31 /** Register the global ⌘/ toggle (disable on hidden/secondary instances). */32 hotkey?: boolean;33 value: string | null;34 onChange?: (key: string) => void;35 className?: string;36 size?: "sm" | "md";37 /** List models whose provider has no key (rendered as Unavailable). */38 allowDisconnected?: boolean;39 multiple?: boolean;40 selected?: Set<string>;41 onToggle?: (key: string) => void;42 buttonLabel?: string;43 /** Show the Smart Router "Auto" entry (selects `AUTO_MODEL_KEY`). */44 showAuto?: boolean;45 /** Custom trigger element (wrapped; click opens the picker). */46 trigger?: React.ReactNode;47 /** Current composer draft — powers the "Recommended" section. */48 draft?: string;49}5051type Item =52 | { type: "auto" }53 | { type: "header"; key: string; label: string; hint?: string; total: number; shown: number; expandable: boolean; expanded: boolean }54 | { type: "row"; model: PolyModel; section: string; why?: string[] }55 | { type: "provider"; provider: ProviderId; count: number };5657const ROW_H = 56;5859export function ModelSelector({ value, onChange, className, size = "md", allowDisconnected = false, multiple, selected, onToggle, buttonLabel, showAuto = false, trigger, draft, hotkey = true }: ModelSelectorProps) {60 const { models, modelsByKey, favorites, recents, labels, connectedProviders, toggleFavorite, setLabel, loadingModels } = useApp();61 const isMobile = useIsMobile();62 const router = useRouter();63 const ctx = useBadgeContext();64 const [open, setOpen] = React.useState(false);65 const [q, setQ] = React.useState("");66 const dq = useDebounced(q, 60);67 const [mode, setMode] = useLocalStorage<RouterMode>(ROUTER_MODE_KEY, "balanced");68 const [expanded, setExpanded] = React.useState<Set<string>>(() => new Set());69 const [active, setActive] = React.useState(0);70 const [menuModel, setMenuModel] = React.useState<PolyModel | null>(null);71 const [labelModel, setLabelModel] = React.useState<PolyModel | null>(null);72 const [profileModel, setProfileModel] = React.useState<PolyModel | null>(null);73 const inputRef = React.useRef<HTMLInputElement>(null);74 const scrollRef = React.useRef<HTMLDivElement>(null);75 // The list mounts only while the dialog is open: expose the element through state so the virtualizer re-subscribes.76 const [scrollEl, setScrollEl] = React.useState<HTMLDivElement | null>(null);77 const setScrollRef = React.useCallback((el: HTMLDivElement | null) => {78 scrollRef.current = el;79 setScrollEl(el);80 }, []);81 const current = value && value !== AUTO_MODEL_KEY ? modelsByKey.get(value) : undefined;82 const isAuto = value === AUTO_MODEL_KEY;8384 React.useEffect(() => {85 if (!hotkey) return;86 const onKey = (e: KeyboardEvent) => {87 if ((e.metaKey || e.ctrlKey) && e.key === "/") {88 e.preventDefault();89 setOpen((o) => !o);90 }91 };92 window.addEventListener("keydown", onKey);93 return () => window.removeEventListener("keydown", onKey);94 }, [hotkey]);9596 React.useEffect(() => {97 if (open) {98 if (!isMobile) setTimeout(() => inputRef.current?.focus(), 40);99 } else {100 setQ("");101 setExpanded(new Set());102 }103 }, [open, isMobile]);104105 const usable = React.useMemo(() => models.filter((m) => (allowDisconnected || connectedProviders.has(m.provider)) && m.status !== "deprecated"), [models, connectedProviders, allowDisconnected]);106107 const intent = React.useMemo(() => parseSearchQuery(dq), [dq]);108 const searching = dq.trim().length > 0 && !isEmptyIntent(intent);109110 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]);111112 const sections = React.useMemo<Section[]>(() => (searching ? [] : buildSelectorSections(usable, { favorites, recents, ctx, mode, draft, perSection: 5 })), [searching, usable, favorites, recents, ctx, mode, draft]);113114 const autoPick = React.useMemo(() => (showAuto && !multiple ? recommendedModels(usable, { favorites, mode, draft, limit: 1 }) : null), [showAuto, multiple, usable, favorites, mode, draft]);115116 const items = React.useMemo<Item[]>(() => {117 const out: Item[] = [];118 if (searching && results) {119 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 });120 for (const r of results) out.push({ type: "row", model: r.model, section: "results", why: r.matches });121 return out;122 }123 if (showAuto && !multiple) out.push({ type: "auto" });124 for (const s of sections) {125 const isOpen = expanded.has(s.key);126 const list = isOpen ? s.all : s.items;127 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 });128 for (const m of list) out.push({ type: "row", model: m, section: s.key });129 }130 // Everything, grouped by provider131 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);132 if (byProvider.length) {133 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 });134 for (const g of byProvider) {135 out.push({ type: "provider", provider: g.p, count: g.list.length });136 for (const m of g.list) out.push({ type: "row", model: m, section: `all:${g.p}` });137 }138 }139 return out;140 }, [searching, results, showAuto, multiple, sections, expanded, usable]);141142 const selectable = React.useMemo(() => items.map((it, i) => (it.type === "row" || it.type === "auto" ? i : -1)).filter((i) => i >= 0), [items]);143144 // Reset the keyboard cursor when the list changes.145 React.useEffect(() => {146 setActive(selectable[0] ?? 0);147 }, [selectable]);148149 const virtualizer = useVirtualizer({150 count: items.length,151 getScrollElement: () => scrollEl,152 estimateSize: (i) => {153 const it = items[i];154 return it.type === "row" ? ROW_H : it.type === "auto" ? 168 : it.type === "provider" ? 32 : 40;155 },156 overscan: 10,157 getItemKey: (i) => {158 const it = items[i];159 return it.type === "row" ? `${it.section}:${it.model.key}` : it.type === "header" ? `h:${it.key}` : it.type === "provider" ? `p:${it.provider}` : "auto";160 },161 });162163 const pick = React.useCallback(164 (m: PolyModel) => {165 if (!allowDisconnected && !connectedProviders.has(m.provider)) return;166 if (multiple && onToggle) {167 onToggle(m.key);168 return;169 }170 onChange?.(m.key);171 setOpen(false);172 },173 [allowDisconnected, connectedProviders, multiple, onToggle, onChange],174 );175176 const pickAuto = () => {177 onChange?.(AUTO_MODEL_KEY);178 setOpen(false);179 };180181 const moveActive = (dir: 1 | -1) => {182 if (!selectable.length) return;183 const pos = selectable.indexOf(active);184 const next = selectable[Math.max(0, Math.min(selectable.length - 1, (pos < 0 ? (dir > 0 ? -1 : 0) : pos) + dir))];185 setActive(next);186 virtualizer.scrollToIndex(next, { align: "auto" });187 };188189 const onKeyNav = (e: React.KeyboardEvent) => {190 if (e.key === "ArrowDown") {191 e.preventDefault();192 moveActive(1);193 } else if (e.key === "ArrowUp") {194 e.preventDefault();195 moveActive(-1);196 } else if (e.key === "Enter") {197 const it = items[active];198 if (!it) return;199 e.preventDefault();200 if (it.type === "row") pick(it.model);201 else if (it.type === "auto") pickAuto();202 }203 };204205 const openCompare = (m: PolyModel) => {206 const keys = [...new Set([current?.key, m.key].filter((k): k is string => Boolean(k)))];207 setOpen(false);208 router.push(`/app/models/compare?m=${encodeURIComponent(keys.join(","))}`);209 };210211 const menuItems = (m: PolyModel): ActionSheetItem[] => {212 const fav = favorites.has(m.key);213 const items: ActionSheetItem[] = [214 { key: "fav", label: fav ? "Remove from favorites" : "Add to favorites", icon: <Star className={cn(fav && "fill-current text-warning")} />, onSelect: () => void toggleFavorite(m.key) },215 { key: "label", label: labels[m.key] ? "Change custom label" : "Set custom label", icon: <Tag />, hint: labels[m.key], onSelect: () => setLabelModel(m) },216 ];217 if (labels[m.key]) items.push({ key: "unlabel", label: "Remove custom label", icon: <X />, onSelect: () => void setLabel(m.key, null) });218 items.push({ key: "profile", label: "View profile", icon: <Info />, onSelect: () => setProfileModel(m) }, { key: "compare", label: current && current.key !== m.key ? `Compare with ${current.displayName}` : "Compare", icon: <GitCompareArrows />, onSelect: () => openCompare(m) });219 return items;220 };221222 const toggleExpanded = (key: string) =>223 setExpanded((prev) => {224 const n = new Set(prev);225 if (n.has(key)) n.delete(key);226 else n.add(key);227 return n;228 });229230 const noProviders = connectedProviders.size === 0 && !loadingModels;231 const triggerLabel = buttonLabel ? (232 <span className="truncate font-medium">{buttonLabel}</span>233 ) : isAuto ? (234 <>235 <Sparkles className="size-3.5 shrink-0 text-accent" />236 <span className="truncate font-medium">Auto</span>237 <span className="hidden truncate text-fg-subtle sm:inline">· {ROUTER_MODES.find((r) => r.value === mode)?.label}</span>238 </>239 ) : current ? (240 <>241 <ProviderIcon provider={current.provider} size={15} />242 <span className="truncate font-medium">{labels[current.key] ?? current.displayName}</span>243 {labels[current.key] ? <span className="hidden truncate text-fg-subtle sm:inline">{current.displayName}</span> : null}244 </>245 ) : (246 <span className="truncate text-fg-muted">{loadingModels ? "Loading models…" : noProviders ? "Connect a provider" : "Choose a model"}</span>247 );248249 const header = (250 <div className="border-b border-border">251 <div className="flex items-center gap-2 px-3">252 <Search className="size-4 shrink-0 text-fg-subtle" />253 <input254 ref={inputRef}255 value={q}256 onChange={(e) => setQ(e.target.value)}257 onKeyDown={onKeyNav}258 placeholder={isMobile ? "Search models…" : "Search — try “cheap vision”, “1M context”, “under $1/M”, “fastest gemini”"}259 className="h-12 min-w-0 flex-1 bg-transparent text-[16px] outline-none placeholder:text-fg-subtle sm:text-[15px]"260 aria-label="Search models"261 role="combobox"262 aria-expanded263 aria-controls="model-selector-list"264 aria-activedescendant={items[active] ? `model-opt-${active}` : undefined}265 autoComplete="off"266 enterKeyHint="done"267 />268 {q ? (269 <button type="button" onClick={() => setQ("")} className="tap rounded-full bg-bg-muted p-1 text-fg-muted" aria-label="Clear search">270 <X className="size-3.5" />271 </button>272 ) : (273 <Kbd className="hidden sm:inline-flex">esc</Kbd>274 )}275 <button type="button" onClick={() => setOpen(false)} className="tap ml-1 rounded-md p-1.5 text-fg-subtle hover:bg-bg-muted hover:text-fg sm:hidden" aria-label="Close">276 <X className="size-4" />277 </button>278 </div>279 {searching && intent.chips.length ? (280 <div className="flex flex-wrap items-center gap-1 px-3 pb-2">281 <span className="text-[11px] text-fg-subtle">Understood:</span>282 {intent.chips.map((c) => (283 <Badge key={c} variant="accent">284 {c}285 </Badge>286 ))}287 </div>288 ) : null}289 </div>290 );291292 const renderItem = (it: Item, index: number) => {293 switch (it.type) {294 case "auto":295 return <AutoCard selected={isAuto} active={active === index} mode={mode} onMode={setMode} onPick={pickAuto} pick={autoPick} id={`model-opt-${index}`} />;296 case "header":297 return (298 <div className={cn("flex items-end justify-between gap-2 px-3 pt-3 pb-1", it.key === "results" && "pt-2")}>299 <div className="min-w-0">300 <span className="text-[11px] font-semibold uppercase tracking-wide text-fg-subtle">{it.label}</span>301 {it.hint ? <span className="ml-2 hidden text-[11px] text-fg-subtle sm:inline">{it.hint}</span> : null}302 </div>303 {it.expandable ? (304 <button type="button" onClick={() => toggleExpanded(it.key)} className="tap inline-flex items-center gap-0.5 text-[11.5px] font-medium text-accent">305 {it.expanded ? "Show less" : `Show all ${it.total}`}306 <ChevronRight className={cn("size-3 transition-transform", it.expanded && "rotate-90")} />307 </button>308 ) : null}309 </div>310 );311 case "provider":312 return (313 <div className="flex items-center gap-1.5 px-3 pt-2 pb-0.5 text-[11px] font-medium text-fg-muted">314 <ProviderIcon provider={it.provider} size={12} /> {PROVIDERS[it.provider].name}315 <span className="text-fg-subtle">· {it.count}</span>316 </div>317 );318 case "row": {319 const m = it.model;320 return (321 <SelectorRow322 id={`model-opt-${index}`}323 model={m}324 ctx={ctx}325 label={labels[m.key]}326 fav={favorites.has(m.key)}327 connected={connectedProviders.has(m.provider)}328 selected={multiple ? Boolean(selected?.has(m.key)) : m.key === value}329 active={active === index}330 multiple={Boolean(multiple)}331 isMobile={isMobile}332 why={it.why}333 onPick={() => pick(m)}334 onFav={() => void toggleFavorite(m.key)}335 onMenu={() => setMenuModel(m)}336 onHover={() => setActive(index)}337 menuItems={() => menuItems(m)}338 />339 );340 }341 }342 };343344 return (345 <>346 {trigger ? (347 <span className="contents" onClick={() => setOpen(true)}>348 {trigger}349 </span>350 ) : (351 <button352 type="button"353 onClick={() => setOpen(true)}354 className={cn(355 "inline-flex max-w-full items-center gap-2 rounded-lg border border-border bg-bg-elevated text-left transition-colors hover:border-border-strong hover:bg-bg-subtle",356 size === "sm" ? "h-8 px-2.5 text-[13px]" : "h-9 px-3 text-sm",357 className,358 )}359 aria-label="Select model"360 title="Select model (⌘/)"361 >362 {triggerLabel}363 <ChevronDown className="size-3.5 shrink-0 text-fg-subtle" />364 </button>365 )}366367 <ResponsiveDialog368 open={open}369 onOpenChange={setOpen}370 title={multiple ? "Choose models" : "Choose a model"}371 hideTitle372 showClose={false}373 size="lg"374 snap="full"375 expandable={false}376 flush377 desktopFlush378 className="sm:h-[min(680px,86vh)]"379 bodyClassName="flex flex-col overflow-hidden"380 header={header}381 footer={382 multiple ? (383 <div className="flex items-center justify-between gap-3 text-[12.5px] text-fg-muted">384 <span>{selected?.size ?? 0} selected (max 4)</span>385 <Button size="sm" className="h-10 px-5 sm:h-8" onClick={() => setOpen(false)}>386 Done387 </Button>388 </div>389 ) : isMobile ? undefined : (390 <div className="flex items-center gap-3 text-[11px] text-fg-subtle">391 <span>392 <Kbd>↑↓</Kbd> navigate393 </span>394 <span>395 <Kbd>↵</Kbd> select396 </span>397 <span>398 <Kbd>⌘/</Kbd> toggle399 </span>400 <Badge variant="outline" className="ml-auto">401 {usable.length} models · {connectedProviders.size} provider{connectedProviders.size === 1 ? "" : "s"}402 </Badge>403 </div>404 )405 }406 >407 {noProviders ? (408 <div className="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center">409 <KeyRound className="size-6 text-fg-subtle" />410 <p className="text-sm font-medium">No provider connected yet</p>411 <p className="max-w-sm text-[13px] text-fg-muted">Add an API key from any supported provider to unlock its models. Keys are encrypted and never leave the server.</p>412 <Button asChild variant="accent" size="sm">413 <Link href="/app/settings/providers">Connect a provider</Link>414 </Button>415 </div>416 ) : (417 <div418 ref={setScrollRef}419 id="model-selector-list"420 role="listbox"421 aria-label="Models"422 tabIndex={-1}423 onKeyDown={onKeyNav}424 // The sheet's swipe-to-dismiss watches its own body (never scrolled here); only let it take over when our list is at the top.425 onPointerDown={(e) => {426 if ((scrollRef.current?.scrollTop ?? 0) > 0) e.stopPropagation();427 }}428 className="min-h-0 flex-1 overflow-y-auto pb-[max(12px,var(--sab))] scrollbar-thin contain-scroll" style={{ overflowAnchor: "none" }}>429 {searching && results && results.length === 0 ? (430 <div className="px-4 py-10 text-center">431 <p className="text-sm font-medium">No models match “{q}”</p>432 <p className="mt-1 text-[12.5px] text-fg-muted">Try a capability (“vision”, “reasoning”), a size (“1M context”), a price (“under $1/M”) or a provider (“gemini”).</p>433 </div>434 ) : (435 <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>436 {virtualizer.getVirtualItems().map((v) => (437 <div key={v.key} data-index={v.index} ref={virtualizer.measureElement} style={{ position: "absolute", top: 0, left: 0, width: "100%", transform: `translateY(${v.start}px)` }}>438 {renderItem(items[v.index], v.index)}439 </div>440 ))}441 </div>442 )}443 </div>444 )}445 </ResponsiveDialog>446447 <ActionSheet open={Boolean(menuModel) && isMobile} onOpenChange={(v) => (!v ? setMenuModel(null) : null)} title={menuModel?.displayName} description={menuModel ? `${PROVIDERS[menuModel.provider].name} · ${menuModel.id}` : undefined} items={menuModel ? menuItems(menuModel) : []} />448449 <PromptDialog open={Boolean(labelModel)} onOpenChange={(v) => (!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)} />450451 <ModelProfile model={profileModel} open={Boolean(profileModel)} onOpenChange={(v) => (!v ? setProfileModel(null) : null)} compareWith={current?.key ?? null} onUse={(m) => pick(m)} />452 </>453 );454}455456/* ------------------------------------------------------------------------------------------------ */457458function AutoCard({ selected, active, mode, onMode, onPick, pick, id }: { selected: boolean; active: boolean; mode: RouterMode; onMode: (m: RouterMode) => void; onPick: () => void; pick: ReturnType<typeof recommendedModels> | null; id: string }) {459 const top = pick?.route.recommended ?? null;460 return (461 <div className="px-2 pt-2">462 <div id={id} role="option" aria-selected={selected} className={cn("rounded-xl border p-3 transition-colors", selected ? "border-accent bg-accent-soft/40" : active ? "border-border-strong bg-bg-subtle" : "border-border")}>463 <button type="button" onClick={onPick} className="flex w-full items-start gap-3 text-left">464 <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent">465 <Sparkles className="size-4" />466 </span>467 <span className="min-w-0 flex-1">468 <span className="flex items-center gap-2">469 <span className="text-[14px] font-semibold">Auto · Smart Router</span>470 {selected ? <Check className="size-4 text-accent" /> : null}471 </span>472 <span className="mt-0.5 block text-[12.5px] leading-4 text-fg-muted">Picks the best connected model for each prompt and shows you why before sending.</span>473 </span>474 </button>475 <Segmented size="sm" value={mode} onChange={onMode} ariaLabel="Router mode" options={ROUTER_MODES.map((r) => ({ value: r.value, label: r.label }))} className="mt-3 w-full" />476 <p className="mt-2 truncate text-[11.5px] text-fg-subtle">477 {top ? (478 <>479 Right now → <span className="font-medium text-fg-muted">{top.model.displayName}</span> · {explainRoute(top)}480 </>481 ) : (482 "Connect a provider to enable routing."483 )}484 </p>485 </div>486 </div>487 );488}489490interface RowProps {491 id: string;492 model: PolyModel;493 ctx: BadgeContext;494 label?: string;495 fav: boolean;496 connected: boolean;497 selected: boolean;498 active: boolean;499 multiple: boolean;500 isMobile: boolean;501 why?: string[];502 onPick: () => void;503 onFav: () => void;504 onMenu: () => void;505 onHover: () => void;506 menuItems: () => ActionSheetItem[];507}508509const stop = (e: React.SyntheticEvent) => e.stopPropagation();510511const SelectorRow = React.memo(function SelectorRow({ id, model: m, ctx, label, fav, connected, selected, active, multiple, isMobile, why, onPick, onFav, onMenu, onHover, menuItems }: RowProps) {512 const press = useLongPress({ onLongPress: onMenu, onClick: onPick, disabled: !isMobile });513 const disabled = !connected;514 return (515 <div516 id={id}517 role="option"518 aria-selected={selected}519 aria-disabled={disabled || undefined}520 tabIndex={-1}521 onMouseEnter={onHover}522 onClick={isMobile ? undefined : onPick}523 onKeyDown={(e) => {524 if (e.key === "Enter" || e.key === " ") {525 e.preventDefault();526 onPick();527 }528 }}529 {...press}530 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")}531 >532 <ProviderIcon provider={m.provider} size={18} className="shrink-0" />533 <div className="min-w-0 flex-1">534 <div className="flex min-w-0 items-center gap-1.5">535 <span className="truncate text-[14px] font-medium leading-5">{m.displayName}</span>536 {label ? <span className="truncate text-[12px] font-medium text-accent">{label}</span> : null}537 <StatusBadge model={m} connected={connected} ctx={ctx} className="hidden min-[400px]:inline-flex" />538 <ModelBadges model={m} ctx={ctx} max={isMobile ? 1 : 3} size="xs" className="hidden min-[400px]:inline-flex" />539 </div>540 <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[11.5px] tabular-nums text-fg-subtle">541 {m.limits?.contextTokens ? <span className="shrink-0">{formatTokens(m.limits.contextTokens)} ctx</span> : null}542 {m.pricing && (m.pricing.inputPerMillion !== undefined || m.pricing.outputPerMillion !== undefined) ? (543 <span className="shrink-0" title="USD per 1M tokens — input / output">544 · {formatPrice(m.pricing.inputPerMillion)} / {formatPrice(m.pricing.outputPerMillion)}545 </span>546 ) : null}547 <CapabilityGlyphs model={m} max={6} className="ml-1" />548 {why?.length ? <span className="hidden truncate text-accent/80 md:inline">· {why.join(", ")}</span> : null}549 </div>550 </div>551 {multiple && selected ? <Check className="size-4 shrink-0 text-accent" /> : null}552 <span className="flex shrink-0 items-center" onPointerDown={stop} onPointerUp={stop} onClick={stop}>553 <button554 type="button"555 onClick={onFav}556 aria-pressed={fav}557 aria-label={fav ? `Remove ${m.displayName} from favorites` : `Add ${m.displayName} to favorites`}558 className={cn("tap flex size-8 items-center justify-center rounded-md transition-colors hover:bg-bg-muted", fav ? "text-warning" : "text-fg-subtle hover:text-fg", !fav && "hover-reveal")}559 >560 <Star key={fav ? "on" : "off"} className={cn("size-4", fav && "fill-current animate-pop")} />561 </button>562 {isMobile ? (563 <button type="button" onClick={onMenu} className="tap flex size-8 items-center justify-center rounded-md text-fg-subtle" aria-label={`More actions for ${m.displayName}`}>564 <MoreHorizontal className="size-4" />565 </button>566 ) : (567 <DropdownMenu>568 <DropdownMenuTrigger asChild>569 <button type="button" className="hover-reveal flex size-8 items-center justify-center rounded-md text-fg-subtle hover:bg-bg-muted hover:text-fg data-[state=open]:opacity-100" aria-label={`More actions for ${m.displayName}`}>570 <MoreHorizontal className="size-4" />571 </button>572 </DropdownMenuTrigger>573 <DropdownMenuContent align="end" onClick={stop}>574 {menuItems().map((it) => (575 <React.Fragment key={it.key}>576 {it.key === "profile" ? <DropdownMenuSeparator /> : null}577 <DropdownMenuItem onSelect={it.onSelect}>578 <span className="[&_svg]:size-3.5">{it.icon}</span> {it.label}579 </DropdownMenuItem>580 </React.Fragment>581 ))}582 </DropdownMenuContent>583 </DropdownMenu>584 )}585 </span>586 </div>587 );588});589