import "./CustomModelDropdown.css"; import { useState, useEffect, useCallback, useMemo, useRef, useId } from "react"; import { THINKING_LEVELS } from "@fusion/core"; import { useTranslation } from "react-i18next"; import { createPortal } from "react-dom"; import type { ModelInfo, ProviderCredentialInstanceSummary } from "../api"; import { filterModels } from "../utils/modelFilter"; import { ProviderIcon } from "./ProviderIcon"; export interface CustomModelDropdownProps { models: ModelInfo[]; value: string; onChange: (value: string) => void; placeholder?: string; disabled?: boolean; id?: string; label: string; /** Optional untouched sentinel value for contexts that need a third lane (e.g. list-view bulk edit). */ noChangeValue?: string; /** Display label for noChangeValue (defaults to "No change"). */ noChangeLabel?: string; /** Display label for the inherited/default option (defaults to "Use default"). */ defaultOptionLabel?: string; /** List of favorite provider names in preferred order */ favoriteProviders?: string[]; /** Called when user toggles a provider's favorite status */ onToggleFavorite?: (provider: string) => void; /** List of favorited model identifiers in format "{provider}/{modelId}" */ favoriteModels?: string[]; /** Called when user toggles a model's favorite status */ onToggleModelFavorite?: (modelId: string) => void; /** Request a wider menu for dense settings surfaces while default callers keep trigger-width sizing. */ menuWidth?: "trigger" | "readable"; /** Optional thinking/reasoning effort value; empty string means inherit/default when defaultThinkingLevel is provided. */ thinkingLevel?: string; /** Called when the optional inline thinking-level selector changes. */ onThinkingLevelChange?: (level: string) => void; /** Effective default thinking level; when supplied, the selector includes an empty "Default (level)" option. */ defaultThinkingLevel?: string; /** Explicitly render the optional inline thinking-level selector even without a change callback. */ showThinkingLevel?: boolean; /** Optional selected credential instance; empty or absent means provider default. */ credentialInstanceId?: string; /** Called when the inline credential-instance selector changes; empty means clear the persisted override. */ onCredentialInstanceChange?: (instanceId: string) => void; /** Available credential instances keyed by provider, as advertised by /api/models. */ credentialInstances?: Record; } interface DropdownPosition { top: number | null; bottom: number | null; left: number; width: number; maxHeight: number; } const COLLAPSED_PROVIDERS_STORAGE_KEY = "fusion-dashboard-model-dropdown-collapsed-providers"; /** * FNXC:ModelDropdown 2026-07-15-00:00: * Provider-group collapse is dashboard-local preference state, not a server setting. Read defensively so SSR, unavailable storage, and malformed legacy data keep every provider expanded instead of breaking a model picker. */ function loadCollapsedProviders(): Set { if (typeof window === "undefined") return new Set(); try { const raw = window.localStorage.getItem(COLLAPSED_PROVIDERS_STORAGE_KEY); if (!raw) return new Set(); const parsed: unknown = JSON.parse(raw); return Array.isArray(parsed) ? new Set(parsed.filter((provider): provider is string => typeof provider === "string")) : new Set(); } catch { return new Set(); } } /** * CustomModelDropdown - A dropdown component combining selection with icon-enhanced provider groups. * * Interaction pattern: * - Closed: Shows trigger button with current selection and provider icon * - Open: Dropdown with search input at top, scrollable list of models grouped by provider with icons * - Filtering: Real-time filtering using filterModels() utility * - Keyboard: Arrow keys navigate, Enter selects, Escape closes, Tab moves focus * * The dropdown listbox is rendered in a portal so it can escape clipping/stacking * contexts created by scrollable modal or board containers while still anchoring to * the trigger button. */ export function CustomModelDropdown({ models, value, onChange, placeholder: placeholderProp, disabled = false, id, label, favoriteProviders = [], onToggleFavorite, favoriteModels = [], onToggleModelFavorite, noChangeValue, noChangeLabel: noChangeLabelProp, defaultOptionLabel: defaultOptionLabelProp, menuWidth = "trigger", thinkingLevel, onThinkingLevelChange, defaultThinkingLevel, showThinkingLevel, credentialInstanceId, onCredentialInstanceChange, credentialInstances, }: CustomModelDropdownProps) { const { t } = useTranslation("app"); const placeholder = placeholderProp ?? t("model.selectPlaceholder", "Select a model…"); const noChangeLabel = noChangeLabelProp ?? t("model.noChange", "No change"); const defaultOptionLabel = defaultOptionLabelProp ?? t("models.useDefault", "Use default"); const [isOpen, setIsOpen] = useState(false); const [localFilter, setLocalFilter] = useState(""); const [highlightedIndex, setHighlightedIndex] = useState(0); const [dropdownPosition, setDropdownPosition] = useState(null); const [portalRoot, setPortalRoot] = useState(null); const [collapsedProviders, setCollapsedProviders] = useState>(loadCollapsedProviders); const generatedThinkingId = useId(); const generatedInstanceId = useId(); const containerRef = useRef(null); const triggerRef = useRef(null); const dropdownRef = useRef(null); const searchInputRef = useRef(null); const listRef = useRef(null); // Filter models based on local filter text const filteredModels = useMemo(() => filterModels(models, localFilter), [models, localFilter]); const hasFilter = localFilter.length > 0; // Group filtered models by provider and sort by favorites const modelsByProvider = useMemo(() => { return filteredModels.reduce>((acc, m) => { (acc[m.provider] ??= []).push(m); return acc; }, {}); }, [filteredModels]); // Build favorited model entries - models that are in the favoriteModels list and in filteredModels const favoritedModelEntries = useMemo(() => { const result: Array<{ model: ModelInfo; fullId: string }> = []; for (const fullId of favoriteModels) { const slashIdx = fullId.indexOf("/"); if (slashIdx === -1) continue; const provider = fullId.slice(0, slashIdx); const modelId = fullId.slice(slashIdx + 1); const model = filteredModels.find((m) => m.provider === provider && m.id === modelId); if (model) { result.push({ model, fullId }); } } return result; }, [favoriteModels, filteredModels]); // Sort providers: favorites first (in order), then alphabetically const sortedProviderEntries = useMemo(() => { const entries = Object.entries(modelsByProvider); const favoritesSet = new Set(favoriteProviders); return entries.sort(([a], [b]) => { const aFavorite = favoritesSet.has(a); const bFavorite = favoritesSet.has(b); if (aFavorite && !bFavorite) return -1; if (!aFavorite && bFavorite) return 1; // Both favorites: sort by favoriteProviders order if (aFavorite && bFavorite) { const aIdx = favoriteProviders.indexOf(a); const bIdx = favoriteProviders.indexOf(b); if (aIdx !== bIdx) return aIdx - bIdx; } // Neither favorite: alphabetical return a.localeCompare(b); }); }, [modelsByProvider, favoriteProviders]); /* FNXC:ModelDropdown 2026-07-15-00:00: Collapsed provider rows are omitted from both the DOM and keyboard option list. An active filter temporarily expands every matching group so search never hides a matching model; the saved collapsed preference resumes after clearing the filter. */ const visibleProviderEntries = useMemo(() => sortedProviderEntries.flatMap(([provider, providerModels]) => { const nonFavoritedModels = providerModels.filter((model) => !favoriteModels.includes(`${model.provider}/${model.id}`)); if (nonFavoritedModels.length === 0) return []; return [{ provider, models: nonFavoritedModels, isCollapsed: !hasFilter && collapsedProviders.has(provider), }]; }), [collapsedProviders, favoriteModels, hasFilter, sortedProviderEntries]); const hasNoChangeOption = typeof noChangeValue === "string" && noChangeValue.length > 0; const shouldShowThinking = showThinkingLevel ?? Boolean(onThinkingLevelChange); const normalizedThinkingLevel = thinkingLevel ?? ""; const hasDefaultThinkingOption = typeof defaultThinkingLevel === "string"; const thinkingSelectId = id ? `${id}-thinking-level` : `${generatedThinkingId}-thinking-level`; /* FNXC:Settings-ThinkingLevel 2026-07-10-00:00: The shared model dropdown can optionally embed a thinking-level selector so task and agent model pickers expose one consistent reasoning-effort affordance, including `xhigh`. The selector stays inert unless a caller opts in with `showThinkingLevel` or `onThinkingLevelChange`, preserving every settings, insights, schedule, workflow, planning, onboarding, and bulk-edit surface that only needs model selection. */ const thinkingOptions = useMemo(() => THINKING_LEVELS.map((level) => ({ value: level, label: t(`models.options.${level}`, level === "xhigh" ? "Very High" : level.charAt(0).toUpperCase() + level.slice(1)), })), [t]); const thinkingBadgeLabel = useMemo(() => { if (!shouldShowThinking) return ""; if (normalizedThinkingLevel) { return thinkingOptions.find((option) => option.value === normalizedThinkingLevel)?.label ?? normalizedThinkingLevel; } if (hasDefaultThinkingOption) { return t("modelSelection.thinkingDefault", "Default ({{level}})", { level: defaultThinkingLevel }); } return thinkingOptions.find((option) => option.value === "off")?.label ?? "Off"; }, [defaultThinkingLevel, hasDefaultThinkingOption, normalizedThinkingLevel, shouldShowThinking, t, thinkingOptions]); // Get current provider from value const currentProvider = useMemo(() => { if (!value || (hasNoChangeOption && value === noChangeValue)) return null; const slashIdx = value.indexOf("/"); return slashIdx === -1 ? null : value.slice(0, slashIdx); }, [hasNoChangeOption, noChangeValue, value]); const instanceOptions = useMemo(() => { if (!currentProvider) return []; const seen = new Set(); const available = credentialInstances?.[currentProvider]?.instances ?? models.find((model) => model.provider === currentProvider)?.credentialInstances ?? []; const deduplicated = available.filter((instance) => { if (!instance.id || seen.has(instance.id)) return false; seen.add(instance.id); return true; }); /* FNXC:ModelDropdown 2026-08-01-09:13: A stale persisted id is retained only after the availability threshold is met. It must not make a single-instance provider sprout this optional control. */ if (deduplicated.length >= 2 && credentialInstanceId && !seen.has(credentialInstanceId)) { deduplicated.push({ id: credentialInstanceId, isDefault: false }); } return deduplicated; }, [credentialInstanceId, credentialInstances, currentProvider, models]); const shouldShowCredentialInstance = instanceOptions.length >= 2; const normalizedCredentialInstanceId = credentialInstanceId ?? ""; const credentialInstanceSelectId = id ? `${id}-credential-instance` : `${generatedInstanceId}-credential-instance`; /* FNXC:ModelDropdown 2026-08-01-09:13: Credential-instance selection is visible exactly when a selected provider advertises at least two distinct instances. Rendering nothing otherwise keeps existing and older-server picker menus structurally unchanged; ownership callbacks determine persistence, never visibility. FNXC:ModelDropdown 2026-08-01-09:13: A stale selected instance remains an explicit option rather than being cleared during render. Only an operator change may remove a persisted override, preventing unrelated saves from silently changing runtime credentials. */ const specialOptions = useMemo(() => { const options: Array<{ type: "default" | "no-change"; value: string; label: string }> = []; if (hasNoChangeOption) { options.push({ type: "no-change", value: noChangeValue, label: noChangeLabel }); } options.push({ type: "default", value: "", label: defaultOptionLabel }); return options; }, [defaultOptionLabel, hasNoChangeOption, noChangeLabel, noChangeValue]); // Build list of all selectable options (for keyboard navigation) // Includes special rows first (optional "No change" + "Use default"), // favorited models next, then provider groups. const optionsList = useMemo(() => { const options: Array<{ type: "default" | "no-change" | "provider" | "model" | "favorite"; value: string; label: string; provider?: string }> = [...specialOptions]; // Add favorited models as pinned rows first for (const { model, fullId } of favoritedModelEntries) { options.push({ type: "favorite", value: fullId, label: model.name, provider: model.provider, }); } visibleProviderEntries.forEach(({ provider, models: providerModels, isCollapsed }) => { options.push({ type: "provider", value: `__group_${provider}`, label: provider, provider }); if (isCollapsed) return; providerModels.forEach((model) => { options.push({ type: "model", value: `${model.provider}/${model.id}`, label: model.name, provider: model.provider, }); }); }); return options; }, [favoritedModelEntries, specialOptions, visibleProviderEntries]); // Get current selection display text const selectedDisplayText = useMemo(() => { if (hasNoChangeOption && value === noChangeValue) { return noChangeLabel; } if (!value) return defaultOptionLabel; const slashIdx = value.indexOf("/"); if (slashIdx === -1) return value; const provider = value.slice(0, slashIdx); const modelId = value.slice(slashIdx + 1); const model = models.find((m) => m.provider === provider && m.id === modelId); return model?.name || value; }, [defaultOptionLabel, hasNoChangeOption, noChangeLabel, noChangeValue, value, models]); // Find index of current value in options list const currentValueIndex = useMemo(() => { return optionsList.findIndex((opt) => opt.value === value); }, [optionsList, value]); /** * Get the effective visible viewport dimensions, preferring * `window.visualViewport` when available (accounts for mobile virtual * keyboards, pinch-zoom, etc.) and falling back to `window` dimensions. */ const getEffectiveViewport = useCallback(() => { const vv = window.visualViewport; if (vv && vv.height > 0 && vv.width > 0) { return { width: vv.width, height: vv.height, offsetTop: vv.offsetTop, offsetLeft: vv.offsetLeft, }; } return { width: window.innerWidth, height: window.innerHeight, offsetTop: 0, offsetLeft: 0, }; }, []); const getPreferredDropdownHeight = useCallback(() => { const { height: viewportHeight } = getEffectiveViewport(); const supportsMatchMedia = typeof window.matchMedia === "function"; const isSmallMobile = supportsMatchMedia ? window.matchMedia("(max-width: 640px)").matches : false; const isMobile = supportsMatchMedia ? window.matchMedia("(max-width: 768px)").matches : false; if (viewportHeight <= 0) return 320; if (isSmallMobile) { return Math.min(viewportHeight * 0.6, 360); } if (isMobile) { return Math.min(viewportHeight * 0.7, 420); } return 320; }, [getEffectiveViewport]); const updateDropdownPosition = useCallback(() => { const trigger = triggerRef.current; if (!trigger) return; const rect = trigger.getBoundingClientRect(); const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport(); const horizontalPadding = 16; const verticalPadding = 16; const gap = 4; const preferredHeight = getPreferredDropdownHeight(); // Calculate space below and above the trigger, relative to the visible viewport. // On mobile with a virtual keyboard, offsetTop/offsetLeft shift the origin. const triggerBottom = rect.bottom - offsetTop; const triggerTop = rect.top - offsetTop; const triggerLeft = rect.left - offsetLeft; const spaceBelow = viewportHeight - triggerBottom; const spaceAbove = triggerTop; const availableBelow = Math.max(spaceBelow - verticalPadding - gap, 160); const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 160); // Determine if we should open upward // Open upward if: not enough space below AND enough space above const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow; const maxHeight = Math.max( Math.min(openUpward ? availableAbove : availableBelow, preferredHeight), 160, ); const maxDropdownWidth = viewportWidth - horizontalPadding * 2; /* FNXC:ModelDropdown 2026-07-01-00:00: Project Models lanes need a readable portaled menu for long provider/model names, but shared model selectors elsewhere must retain trigger-width behavior unless they opt in. Clamp the widened target to the effective viewport, including visualViewport offsets, so desktop and mobile keyboards never create offscreen click targets. */ const preferredDropdownWidth = menuWidth === "readable" ? Math.max(rect.width, Math.min(rect.width * 1.6, viewportWidth * 0.72)) : rect.width; const dropdownWidth = Math.min(preferredDropdownWidth, maxDropdownWidth); const left = Math.min( Math.max(triggerLeft, horizontalPadding), viewportWidth - horizontalPadding - dropdownWidth, ) + offsetLeft; /* FNXC:ModelDropdown 2026-08-01-07:11: The model menu's maxHeight includes a 160px scroll floor, so upward top placement based on that cap separates short model lists from their trigger. Anchor the bottom instead; the visual-viewport offset preserves the existing effective-viewport coordinate conversion for keyboard and zoom cases. */ const top = openUpward ? null : Math.min(triggerBottom + gap + offsetTop, viewportHeight + offsetTop - verticalPadding - maxHeight); const bottom = openUpward ? viewportHeight + offsetTop - rect.top + gap : null; setDropdownPosition({ top, bottom, left, width: dropdownWidth, maxHeight, }); }, [getEffectiveViewport, getPreferredDropdownHeight, menuWidth]); useEffect(() => { setPortalRoot(document.body); }, []); // Initialise the highlighted option on open. We only seed once per open // session — re-seeding on every optionsList change (which fires on each // keystroke into the filter) was snapping highlightedIndex back to the // current model's position, and the scrollIntoView effect below then // yanked the list back to that row, making filtering feel like the // dropdown was "refreshing" or fighting the user's scroll. const didInitHighlightRef = useRef(false); useEffect(() => { if (!isOpen) { didInitHighlightRef.current = false; return; } if (didInitHighlightRef.current) return; if (optionsList.length === 0) return; const selectableIndex = optionsList.findIndex( (opt, idx) => idx >= (currentValueIndex >= 0 ? currentValueIndex : 0) && opt.type !== "provider" ); setHighlightedIndex(selectableIndex >= 0 ? selectableIndex : 0); didInitHighlightRef.current = true; }, [isOpen, optionsList, currentValueIndex]); // When the filter changes, reset to the first option and scroll the list // back to the top instead of keeping a now-invalid highlight position. useEffect(() => { if (!isOpen) return; if (!didInitHighlightRef.current) return; setHighlightedIndex(0); if (listRef.current) listRef.current.scrollTop = 0; }, [localFilter, isOpen]); // Focus search input and position dropdown when opening useEffect(() => { if (!isOpen) return; updateDropdownPosition(); const rafId = requestAnimationFrame(() => searchInputRef.current?.focus()); return () => cancelAnimationFrame(rafId); }, [isOpen, updateDropdownPosition]); // Keep portaled dropdown anchored during viewport and container scrolling. // Also reposition when the visual viewport changes (mobile virtual keyboard, // pinch-zoom, etc.). useEffect(() => { if (!isOpen) return; const handleReposition = () => updateDropdownPosition(); window.addEventListener("resize", handleReposition); window.addEventListener("scroll", handleReposition, true); // Listen for visual viewport changes (virtual keyboard open/close, zoom) const vv = window.visualViewport; if (vv) { vv.addEventListener("resize", handleReposition); vv.addEventListener("scroll", handleReposition); } return () => { window.removeEventListener("resize", handleReposition); window.removeEventListener("scroll", handleReposition, true); if (vv) { vv.removeEventListener("resize", handleReposition); vv.removeEventListener("scroll", handleReposition); } }; }, [isOpen, updateDropdownPosition]); // Click outside to close, treating both trigger container and portaled menu as inside. useEffect(() => { if (!isOpen) return; const handlePointerDown = (e: MouseEvent) => { const target = e.target as Node; const clickedInsideTrigger = containerRef.current?.contains(target); const clickedInsideDropdown = dropdownRef.current?.contains(target); if (!clickedInsideTrigger && !clickedInsideDropdown) { setIsOpen(false); setLocalFilter(""); } }; document.addEventListener("mousedown", handlePointerDown); return () => document.removeEventListener("mousedown", handlePointerDown); }, [isOpen]); /* FNXC:ModelDropdown 2026-08-12-21:56: Every dashboard host must treat the document.body model-menu portal as inside its model control. Stop native pointer and mouse events at the portal boundary before document-level outside-close listeners run, so provider collapse/expand remains interactive on desktop and mobile without requiring each consumer to duplicate the portal exemption. FNXC:ModelDropdown 2026-08-15-12:27: Mobile hosts may close from touchstart before a virtual-keyboard re-anchor sends the synthesized click to a backdrop. Stop touchstart and touchend too, but never click: React option and favorite handlers dispatch through this body portal. */ useEffect(() => { const dropdown = dropdownRef.current; if (!dropdown) return; const stopPortalOutsideClose = (event: Event) => event.stopPropagation(); const events = ["pointerdown", "mousedown", "touchstart", "touchend"] as const; events.forEach((eventName) => dropdown.addEventListener(eventName, stopPortalOutsideClose)); return () => events.forEach((eventName) => dropdown.removeEventListener(eventName, stopPortalOutsideClose)); }, [dropdownPosition, isOpen]); // Keyboard navigation const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { switch (e.key) { case "ArrowDown": e.preventDefault(); if (!isOpen) { setIsOpen(true); } else { let nextIndex = highlightedIndex; for (let i = 1; i <= optionsList.length; i++) { const idx = (highlightedIndex + i) % optionsList.length; if (optionsList[idx]?.type !== "provider") { nextIndex = idx; break; } } setHighlightedIndex(nextIndex); } break; case "ArrowUp": e.preventDefault(); if (isOpen) { let prevIndex = highlightedIndex; for (let i = 1; i <= optionsList.length; i++) { const idx = (highlightedIndex - i + optionsList.length) % optionsList.length; if (optionsList[idx]?.type !== "provider") { prevIndex = idx; break; } } setHighlightedIndex(prevIndex); } break; case "Enter": e.preventDefault(); if (isOpen) { const option = optionsList[highlightedIndex]; if (option && option.type !== "provider" && option.type !== "favorite") { onChange(option.value); setIsOpen(false); setLocalFilter(""); } } else { setIsOpen(true); } break; case "Escape": e.preventDefault(); setIsOpen(false); setLocalFilter(""); break; case "Tab": if (isOpen) { setIsOpen(false); setLocalFilter(""); } break; } }, [isOpen, highlightedIndex, optionsList, onChange] ); const handleSelect = useCallback( (optionValue: string) => { onChange(optionValue); setIsOpen(false); setLocalFilter(""); }, [onChange] ); const handleClearFilter = useCallback(() => { setLocalFilter(""); searchInputRef.current?.focus(); }, []); const handleToggleCollapsedProvider = useCallback((provider: string) => { setCollapsedProviders((previous) => { const next = new Set(previous); if (next.has(provider)) { next.delete(provider); } else { next.add(provider); } try { if (typeof window !== "undefined") { window.localStorage.setItem(COLLAPSED_PROVIDERS_STORAGE_KEY, JSON.stringify([...next].sort())); } } catch { // Storage failures must not prevent the in-memory affordance from working. } return next; }); setHighlightedIndex(0); }, []); const handleTriggerClick = useCallback(() => { if (!disabled) { setIsOpen((prev) => !prev); } }, [disabled]); // Scroll highlighted option into view useEffect(() => { if (isOpen && listRef.current) { const highlightedEl = listRef.current.querySelector(`[data-index="${highlightedIndex}"]`); if (highlightedEl && typeof highlightedEl.scrollIntoView === "function") { highlightedEl.scrollIntoView({ block: "nearest" }); } } }, [highlightedIndex, isOpen]); const dropdownContent = isOpen && dropdownPosition ? (
setLocalFilter(e.target.value)} onClick={(e) => e.stopPropagation()} /> {hasFilter && ( )}
{t("models.count", { count: filteredModels.length, defaultValue_one: "{{count}} model", defaultValue_other: "{{count}} models" })}
{shouldShowCredentialInstance && (
e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
)} {shouldShowThinking && (
e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
)}
{specialOptions.map((option, index) => (
handleSelect(option.value)} onMouseEnter={() => setHighlightedIndex(index)} role="option" aria-selected={value === option.value} > {option.label}
))} {/* Favorited models as pinned rows */} {favoritedModelEntries.length > 0 && ( <>
{favoritedModelEntries.map(({ model, fullId }, idx) => { const optionIndex = idx + specialOptions.length; const isHighlighted = highlightedIndex === optionIndex; const isSelected = value === fullId; return (
handleSelect(fullId)} onMouseEnter={() => setHighlightedIndex(optionIndex)} role="option" aria-selected={isSelected} > {model.name} {model.id} {onToggleModelFavorite && ( )}
); })}
)} {visibleProviderEntries.map(({ provider, models: providerModels, isCollapsed }) => { const groupStartIndex = optionsList.findIndex((opt) => opt.value === `__group_${provider}`); const isFavorite = favoriteProviders.includes(provider); const isExpanded = !isCollapsed; return (
{provider} {onToggleFavorite && ( )}
{!isCollapsed && providerModels.map((model) => { const optionValue = `${model.provider}/${model.id}`; const optionIndex = optionsList.findIndex((opt) => opt.value === optionValue); const isHighlighted = highlightedIndex === optionIndex; const isSelected = value === optionValue; const isFavorited = favoriteModels.includes(optionValue); return (
handleSelect(optionValue)} onMouseEnter={() => setHighlightedIndex(optionIndex)} role="option" aria-selected={isSelected} > {model.name} {model.id} {onToggleModelFavorite && ( )}
); })}
); })} {filteredModels.length === 0 && hasFilter && (
{t("models.noResults", "No models match '{{filter}}'", { filter: localFilter })}
)}
) : null; return ( <>
{portalRoot && dropdownContent ? createPortal(dropdownContent, portalRoot) : null} ); }