From cfb24dc052a680b0ec8014cc8d42a4338b2b398e Mon Sep 17 00:00:00 2001 From: Siddharth Gelera Date: Wed, 1 Jul 2026 09:51:30 +0530 Subject: [PATCH] feat(ModelReasoningPicker): Add search to model picker --- .../pickers/ModelReasoningPicker.tsx | 299 ++++++++++++++++-- 1 file changed, 276 insertions(+), 23 deletions(-) diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.tsx index b24605442..f4502faee 100644 --- a/apps/app/src/components/pickers/ModelReasoningPicker.tsx +++ b/apps/app/src/components/pickers/ModelReasoningPicker.tsx @@ -14,6 +14,7 @@ import { stripModelBrandPrefix } from "./model-brand-prefix"; import { REASONING_LABELS } from "@/lib/reasoning-labels"; import { Button } from "@/components/ui/button.js"; import { Icon, type IconName } from "@/components/ui/icon.js"; +import { Input } from "@/components/ui/input.js"; import { COARSE_POINTER_ICON_SIZE_CLASS, COARSE_POINTER_ICON_SIZE_SHRINK_CLASS, @@ -36,6 +37,7 @@ import { import { cn } from "@/lib/utils"; import { useSystemExecutionOptions } from "@/hooks/queries/system-queries"; import { useIsCompactViewport } from "@/components/ui/hooks/use-compact-viewport.js"; +import { usePointerCoarse } from "@/components/ui/hooks/use-pointer-coarse.js"; import { OPTION_BASE_CLASS_NAME, OPTION_INTERACTIVE_CLASS_NAME, @@ -67,6 +69,28 @@ function splitModelLabelTag(label: string): ModelLabelParts { return { base: match[1], tag: match[2] }; } +/** + * Build a loose fuzzy RegExp from a plain-text query. + * Each character is matched in order with `.*` between them, so + * "gpt4" matches "GPT-4 Turbo". + */ +function buildFuzzyRegex(query: string): RegExp { + const pattern = query + .split("") + .map((c) => c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join(".*"); + return new RegExp(pattern, "i"); +} + +function fuzzyFilter>( + options: readonly T[], + normalizedQuery: string, +): readonly T[] { + if (!normalizedQuery) return options; + const regex = buildFuzzyRegex(normalizedQuery); + return options.filter((option) => regex.test(option.label)); +} + interface ModelReasoningPickerProps { // Provider state providerOptions: readonly PickerOption[]; @@ -153,7 +177,13 @@ export function ModelReasoningPicker({ footerAction, }: ModelReasoningPickerProps) { const isCompactViewport = useIsCompactViewport(); + const isPointerCoarse = usePointerCoarse(); const [open, setOpen] = useState(defaultOpen); + const [searchQuery, setSearchQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(-1); + const searchInputRef = useRef(null); + const normalizedQuery = searchQuery.trim().toLowerCase(); + // While the popover is open, the user can browse other providers without // committing. `previewProviderId` tracks which provider tab is active; // null means "showing the committed provider". @@ -188,9 +218,9 @@ export function ModelReasoningPicker({ const selectedModelLoadErrorText = selectedModelLoadErrorMatches && modelLoadError ? formatModelLoadErrorText({ - error: modelLoadError, - providerLabel: selectedProviderLabel, - }) + error: modelLoadError, + providerLabel: selectedProviderLabel, + }) : "Could not load models."; // Strip the brand prefix at render — the trigger always shows the committed // provider, so we use `selectedProviderId` (not `activeProviderId`, which @@ -300,9 +330,9 @@ export function ModelReasoningPicker({ const activeModelLoadErrorMessage = activeModelLoadErrorMatches && activeModelLoadError ? formatModelLoadErrorText({ - error: activeModelLoadError, - providerLabel: activeProviderLabel, - }) + error: activeModelLoadError, + providerLabel: activeProviderLabel, + }) : null; const activeModelLoadFailed = isPreviewing ? previewQuery.isError || activeModelLoadErrorMatches @@ -322,6 +352,16 @@ export function ModelReasoningPicker({ providerOptions.length > 1 && (!isShowingModelError || activeModelErrorIsProviderSpecific); + // Filtered model lists (client-side fuzzy search scoped to the active + // provider). + const filteredModelOptions = useMemo(() => { + return fuzzyFilter(activeModelOptions, normalizedQuery); + }, [activeModelOptions, normalizedQuery]); + + const filteredMoreModelOptions = useMemo(() => { + return fuzzyFilter(activeMoreModelOptions, normalizedQuery); + }, [activeMoreModelOptions, normalizedQuery]); + // When previewing a different provider, resolve fast-mode toggle from that // provider's capabilities instead of the committed provider's. const effectiveShowFastModeToggle = @@ -347,6 +387,8 @@ export function ModelReasoningPicker({ setPreviewProviderId(null); setShowMoreModels(false); setMoreModelsOpen(false); + setSearchQuery(""); + setActiveIndex(-1); }, []); const openSub = useCallback(() => { @@ -405,6 +447,130 @@ export function ModelReasoningPicker({ setMoreModelsOpen(false); }, [footerAction]); + // Keyboard navigation inside the model list while the search input has focus. + const handleSearchKeyDown = useCallback>( + (event) => { + const getTotal = () => { + let count = filteredModelOptions.length; + if (filteredMoreModelOptions.length > 0) { + count += 1; // toggle or submenu trigger + if (isCompactViewport && showMoreModels) { + count += filteredMoreModelOptions.length; + } + } + return count; + }; + + if (event.key === "ArrowDown") { + event.preventDefault(); + const total = getTotal(); + if (total === 0) return; + setActiveIndex((current) => + current >= total - 1 ? 0 : current + 1, + ); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + const total = getTotal(); + if (total === 0) return; + setActiveIndex((current) => + current <= 0 ? total - 1 : current - 1, + ); + return; + } + + if (event.key === "Enter") { + event.preventDefault(); + if (activeIndex < 0) return; + + let idx = activeIndex; + if (idx < filteredModelOptions.length) { + handleModelSelect(filteredModelOptions[idx]!.value); + return; + } + idx -= filteredModelOptions.length; + + if (filteredMoreModelOptions.length > 0) { + if (idx === 0) { + if (isCompactViewport) { + setShowMoreModels((current) => !current); + } else { + openSub(); + } + return; + } + idx -= 1; + if ( + isCompactViewport && + showMoreModels && + idx >= 0 && + idx < filteredMoreModelOptions.length + ) { + handleModelSelect(filteredMoreModelOptions[idx]!.value); + return; + } + } + } + + }, + [ + activeIndex, + filteredModelOptions, + filteredMoreModelOptions, + isCompactViewport, + showMoreModels, + handleModelSelect, + openSub, + ], + ); + + // Clamp active index when the visible list changes size. + useEffect(() => { + let count = filteredModelOptions.length; + if (filteredMoreModelOptions.length > 0) { + count += 1; + if (isCompactViewport && showMoreModels) { + count += filteredMoreModelOptions.length; + } + } + setActiveIndex((current) => { + if (count === 0) return -1; + if (current >= count) return count - 1; + if (current < 0) return 0; + return current; + }); + }, [ + filteredModelOptions.length, + filteredMoreModelOptions.length, + isCompactViewport, + showMoreModels, + ]); + + // Scroll the active item into view. + useEffect(() => { + if (activeIndex < 0) return; + const el = document.getElementById(`model-nav-${activeIndex}`); + el?.scrollIntoView({ block: "nearest" }); + }, [activeIndex]); + + // Clear search when the active provider changes (tab switch). + useEffect(() => { + setSearchQuery(""); + setActiveIndex(-1); + }, [activeProviderId]); + + // Auto-focus the search input on open (desktop only). + useEffect(() => { + if (!open || isCompactViewport || isPointerCoarse) return; + const frame = window.requestAnimationFrame(() => { + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + }); + return () => window.cancelAnimationFrame(frame); + }, [open, isCompactViewport, isPointerCoarse]); + const TriggerIcon = hasSelectedModel ? ProviderIcon : undefined; const triggerTitleModelLabel = modelIsLoading ? "Loading models..." @@ -482,6 +648,9 @@ export function ModelReasoningPicker({ return trigger; } + const showSearchInput = + hasActiveModelOptions && !activeModelIsLoading && !isShowingModelError; + return ( {trigger} @@ -545,6 +714,15 @@ export function ModelReasoningPicker({ ) : null} + {showSearchInput ? ( + + ) : null} + {/* Model list — keyed by the active provider so each provider mounts a fresh subtree. Rows are keyed by model id, but reusing one subtree @@ -556,7 +734,7 @@ export function ModelReasoningPicker({ className={cn( "overflow-y-auto px-1 pb-1 pt-0", !isCompactViewport && - "max-h-[min(250px,var(--radix-popover-content-available-height,250px)-80px)]", + "max-h-[min(250px,var(--radix-popover-content-available-height,250px)-80px)]", )} > {isShowingModelError ? null : ( @@ -573,9 +751,11 @@ export function ModelReasoningPicker({ ) : hasActiveModelOptions ? ( <> - {activeModelOptions.map((option) => ( + {filteredModelOptions.map((option, index) => ( handleModelSelect(option.value)} /> ))} - {activeMoreModelOptions.length > 0 ? ( + {filteredMoreModelOptions.length > 0 ? ( isCompactViewport ? ( <> setShowMoreModels((current) => !current) } /> {showMoreModels - ? activeMoreModelOptions.map((option) => ( - handleModelSelect(option.value)} - /> - )) + ? filteredMoreModelOptions.map((option, idx) => ( + handleModelSelect(option.value)} + /> + )) : null} ) : ( ) ) : null} + {normalizedQuery && + filteredModelOptions.length === 0 && + filteredMoreModelOptions.length === 0 ? ( +
+ No models match your search +
+ ) : null} ) : (
void; + isActive?: boolean; + id?: string; onPointerEnter?: PointerEventHandler; onKeyDown?: KeyboardEventHandler; }) { @@ -747,12 +952,14 @@ function MoreModelsToggleRow({ return (