SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
14 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
27.6 KB · 589 lines tsx
Raw Blame History
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