import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import { useMemo, useState } from 'react' import { Codicon } from '@/components/ui/codicon' import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, dropdownMenuRow, DropdownMenuSearch, dropdownMenuSectionLabel, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger } from '@/components/ui/dropdown-menu' import { Skeleton } from '@/components/ui/skeleton' import type { HermesGateway } from '@/hermes' import { getGlobalModelOptions } from '@/hermes' import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label' import { cn } from '@/lib/utils' import { $visibleModels, collapseModelFamilies, DEFAULT_VISIBLE_PER_PROVIDER, type ModelFamily, modelVisibilityKey, setModelVisibilityOpen } from '@/store/model-visibility' import { $activeSessionId, $currentFastMode, $currentModel, $currentProvider, $currentReasoningEffort } from '@/store/session' import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes' import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu' interface ModelMenuPanelProps { gateway?: HermesGateway onSelectModel: (selection: { model: string; persistGlobal: boolean; provider: string }) => Promise | void requestGateway: (method: string, params?: Record) => Promise } interface ProviderGroup { families: ModelFamily[] provider: ModelOptionProvider } export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: ModelMenuPanelProps) { const [search, setSearch] = useState('') // Reactive session state is read from the stores here (not drilled in), so // toggling effort/fast/model re-renders this panel in place without forcing // the parent to rebuild the menu content (which would close the dropdown). const activeSessionId = useStore($activeSessionId) const currentFastMode = useStore($currentFastMode) const currentModel = useStore($currentModel) const currentProvider = useStore($currentProvider) const currentReasoningEffort = useStore($currentReasoningEffort) const visibleModels = useStore($visibleModels) const modelOptions = useQuery({ queryKey: ['model-options', activeSessionId || 'global'], queryFn: (): Promise => { if (gateway && activeSessionId) { return gateway.request('model.options', { session_id: activeSessionId }) } return getGlobalModelOptions() } }) const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '') const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '') const loading = modelOptions.isPending && !modelOptions.data const error = modelOptions.error ? modelOptions.error instanceof Error ? modelOptions.error.message : String(modelOptions.error) : null const providers = modelOptions.data?.providers const switchTo = (model: string, provider: string) => onSelectModel({ model, persistGlobal: !activeSessionId, provider }) const groups = useMemo( () => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, visibleModels), [providers, search, optionsModel, optionsProvider, visibleModels] ) return ( <> {loading ? ( {Array.from({ length: 4 }, (_, index) => ( event.preventDefault()} > ))} ) : error ? ( {error} ) : groups.length === 0 ? ( No models found ) : (
{groups.map(group => ( {group.provider.name} {group.families.map(family => { // The active id may be the base or its -fast sibling; either // way this one family row represents both. const activeId = group.provider.slug === optionsProvider && (optionsModel === family.id || optionsModel === family.fastId) ? optionsModel : null const isCurrent = activeId !== null const name = modelDisplayParts(family.id).name // Capabilities are looked up against the active/base id; the // -fast variant carries the same param support as its base. const caps = group.provider.capabilities?.[family.id] // Single source of truth for the active row's fast state — keeps // the row label in lock-step with the submenu's Fast toggle and // handles the standalone `-fast` id case. const fastControl = resolveFastControl( activeId ?? family.id, group.provider.models ?? [], caps?.fast ?? false, currentFastMode ) // Grayed text: active row shows live state (Fast + effort); // others show a fast-capability hint. const meta = isCurrent ? [fastControl.kind !== 'none' && fastControl.on ? 'Fast' : null, reasoningEffortLabel(currentReasoningEffort) || 'Med'] .filter(Boolean) .join(' ') : caps?.fast || family.fastId ? 'Fast' : '' // Every row is a hover-Edit submenu trigger. Activating it // (pointer or keyboard) switches to the family's base model; // the Fast toggle inside swaps to the -fast sibling (or flips // the speed param). The sub-trigger has no `onSelect`, so wire // both click and Enter/Space for keyboard parity. const activate = () => { if (!isCurrent) { void switchTo(family.id, group.provider.slug) } } return ( { if (event.key === 'Enter' || event.key === ' ') { activate() } }} > {name} {meta ? {meta} : null} {isCurrent ? : null} switchTo(family.id, group.provider.slug)} onSelectModel={nextModel => switchTo(nextModel, group.provider.slug)} reasoning={caps?.reasoning ?? true} requestGateway={requestGateway} /> ) })} ))}
)} setModelVisibilityOpen(true)} > Edit Models… ) } // Collapsed we show the user's chosen models (or the curated default); typing // spans every available model so anything is reachable past the cut. const PER_PROVIDER_SEARCH = 12 function groupModels( providers: ModelOptionProvider[], search: string, current: { model: string; provider: string }, visible: Set | null ): ProviderGroup[] { const q = search.trim().toLowerCase() const groups: ProviderGroup[] = [] for (const provider of providers) { const allFamilies = collapseModelFamilies(provider.models ?? []) if (allFamilies.length === 0) { continue } const matches = (family: ModelFamily) => `${family.id} ${family.fastId ?? ''} ${provider.name} ${provider.slug} ${displayModelName(family.id)}` .toLowerCase() .includes(q) // Which model ids to show (the active one is always added on top of this). let shown: Set if (q) { // Search spans every family, regardless of visibility. shown = new Set(allFamilies.filter(matches).map(family => family.id)) } else if (visible) { // User has customized which models show — honor their selection exactly. shown = new Set( allFamilies.filter(family => visible.has(modelVisibilityKey(provider.slug, family.id))).map(family => family.id) ) } else { // Default: curated top-N families per provider. shown = new Set(allFamilies.slice(0, DEFAULT_VISIBLE_PER_PROVIDER).map(family => family.id)) } // Always include the active model — but keep every row in the provider's // stable curated order (filter `allFamilies`, never reorder), so selecting // a model can't shuffle the list. const activeId = provider.slug === current.provider && current.model ? allFamilies.find(family => family.id === current.model || family.fastId === current.model)?.id : undefined let families = allFamilies.filter(family => shown.has(family.id) || family.id === activeId) if (q) { families = families.slice(0, PER_PROVIDER_SEARCH) } if (families.length > 0) { groups.push({ families, provider }) } } // Stable, logical group order: alphabetical by provider name. (The backend // floats the current provider first, which would reshuffle on every switch.) groups.sort((a, b) => a.provider.name.localeCompare(b.provider.name)) return groups }