diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 507a1901..4d7ec3ac 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -1,6 +1,6 @@ # CCS Codebase Summary -Last Updated: 2026-03-16 +Last Updated: 2026-03-17 Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, and account-context validation hardening. @@ -365,6 +365,7 @@ ui/src/ │ ├── button.tsx │ ├── card.tsx │ ├── dialog.tsx +│ ├── searchable-select.tsx # Shared searchable combobox for model pickers │ ├── sidebar.tsx # Custom sidebar (674 lines) │ └── [UI primitives...] │ diff --git a/docs/cursor-integration.md b/docs/cursor-integration.md index 3fa9897d..8278544e 100644 --- a/docs/cursor-integration.md +++ b/docs/cursor-integration.md @@ -82,7 +82,7 @@ Available controls: - Auth actions (auto-detect, manual import) - Daemon actions (start/stop) - Runtime config (port, auto-start, ghost mode) -- Models list +- Models list with searchable combobox filtering for large catalogs - Raw editor for `~/.ccs/cursor.settings.json` ## Raw Settings and Unified Config Sync diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 6da3c1b8..5d47170a 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-03-16 +Last Updated: 2026-03-17 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -42,6 +42,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes - **#724**: Codex startup is now free-plan safe. CCS defaults new Codex sessions to a cross-plan model and auto-repairs stale paid-only Codex defaults when the active account is on the free plan. +- **#737**: Dashboard model pickers in Cursor, Copilot, and CLIProxy now use a searchable combobox with autofocus and explicit no-results states for large model catalogs. ### Maintainability Hardening Kickoff diff --git a/ui/src/components/cliproxy/categorized-model-selector.tsx b/ui/src/components/cliproxy/categorized-model-selector.tsx index c52f8794..e80d9d1d 100644 --- a/ui/src/components/cliproxy/categorized-model-selector.tsx +++ b/ui/src/components/cliproxy/categorized-model-selector.tsx @@ -5,15 +5,7 @@ import { useMemo } from 'react'; import { Badge } from '@/components/ui/badge'; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; +import { SearchableSelect } from '@/components/ui/searchable-select'; import { Skeleton } from '@/components/ui/skeleton'; import { Cpu } from 'lucide-react'; import type { CliproxyModelsResponse } from '@/lib/api-client'; @@ -94,36 +86,37 @@ export function CategorizedModelSelector({ } return ( - + ({ + key: category, + label: ( +
+ + {t(`cliproxyModelCategory.${display.key}`)} + + + {models.length} + +
+ ), + }))} + options={sortedCategories.flatMap(({ category, models }) => + models.map((model) => ({ + value: model.id, + groupKey: category, + searchText: model.id, + keywords: [category], + itemContent: {model.id}, + })) + )} + /> ); } diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index 5b8d5ec6..af2a1afb 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -5,22 +5,15 @@ */ import { useMemo } from 'react'; -import { Badge } from '@/components/ui/badge'; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Skeleton } from '@/components/ui/skeleton'; -import { AlertTriangle, AlertCircle, Check } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import { getCodexEffortDisplay } from '@/lib/codex-effort'; +import { AlertCircle, AlertTriangle } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { Badge } from '@/components/ui/badge'; +import { SearchableSelect } from '@/components/ui/searchable-select'; +import { Skeleton } from '@/components/ui/skeleton'; +import { getCodexEffortDisplay } from '@/lib/codex-effort'; +import { cn } from '@/lib/utils'; + /** Model entry from catalog */ export interface ModelEntry { id: string; @@ -67,6 +60,53 @@ interface ProviderModelSelectorProps { className?: string; } +function PaidBadge({ label }: { label: string }) { + return ( + + {label} + + ); +} + +function StatusBadges({ + model, + brokenLabel, + deprecatedLabel, +}: { + model: Pick; + brokenLabel: string; + deprecatedLabel: string; +}) { + return ( + <> + {model.broken && ( + + {brokenLabel} + + )} + {model.deprecated && ( + + {deprecatedLabel} + + )} + + ); +} + +function CodexEffortBadge({ modelId }: { modelId: string | undefined }) { + const codexEffort = getCodexEffortDisplay(modelId); + if (!codexEffort) return null; + + return ( + + {codexEffort.label} + + ); +} + export function ProviderModelSelector({ catalog, isLoading, @@ -79,18 +119,18 @@ export function ProviderModelSelector({ const { t } = useTranslation(); const resolvedPlaceholder = placeholder ?? t('providerModelSelector.selectModel'); - // Group models by tier const groupedModels = useMemo(() => { if (!catalog?.models) return { free: [], paid: [] }; return { - free: catalog.models.filter((m) => !m.tier || m.tier === 'free'), - paid: catalog.models.filter((m) => m.tier === 'paid'), + free: catalog.models.filter((model) => !model.tier || model.tier === 'free'), + paid: catalog.models.filter((model) => model.tier === 'paid'), }; }, [catalog]); - const selectedModel = useMemo(() => { - return catalog?.models.find((m) => m.id === value); - }, [catalog, value]); + const selectedModel = useMemo( + () => catalog?.models.find((model) => model.id === value), + [catalog, value] + ); if (isLoading) { return ; @@ -98,76 +138,69 @@ export function ProviderModelSelector({ if (!catalog || catalog.models.length === 0) { return ( -
+
{t('providerModelSelector.noModelsForProvider')}
); } - const renderModelItem = (model: ModelEntry) => ( - -
- {model.name} - {model.broken && ( - - {t('providerModelSelector.broken')} - - )} - {model.deprecated && ( - - {t('providerModelSelector.deprecated')} - - )} - {value === model.id && } -
-
- ); - return (
- + + ), + }, + { + key: 'paid', + label: ( + {t('providerModelSelector.paidTier')} + ), + }, + ]} + options={[...groupedModels.free, ...groupedModels.paid].map((model) => ({ + value: model.id, + groupKey: model.tier === 'paid' ? 'paid' : 'free', + searchText: `${model.name} ${model.id}`, + keywords: [model.tier ?? 'free'], + triggerContent: ( +
+ {model.name} + {model.tier === 'paid' && } +
+ ), + itemContent: ( +
+ {model.name} + +
+ ), + }))} + /> - {/* Warning for broken/deprecated models */} {selectedModel?.broken && ( -
- +
+

{t('providerModelSelector.modelKnownIssues')}

{selectedModel.issueUrl && ( @@ -185,8 +218,8 @@ export function ProviderModelSelector({ )} {selectedModel?.deprecated && ( -
- +
+

{t('providerModelSelector.modelDeprecated')}

{selectedModel.deprecationReason && ( @@ -196,7 +229,6 @@ export function ProviderModelSelector({
)} - {/* Model description */} {selectedModel?.description && !selectedModel.broken && !selectedModel.deprecated && (

{selectedModel.description}

)} @@ -226,27 +258,26 @@ export function ModelMappingSelector({ return (
- + ({ + value: model.id, + searchText: `${model.name} ${model.id}`, + triggerContent: {model.id}, + itemContent: ( +
+ {model.name} + {model.tier === 'paid' && } +
+ ), + }))} + />
); } @@ -272,10 +303,50 @@ export function FlexibleModelSelector({ disabled, }: FlexibleModelSelectorProps) { const { t } = useTranslation(); - // Combine catalog models (recommended) with all available models - const catalogModelIds = new Set(catalog?.models.map((m) => m.id) || []); + const catalogModelIds = new Set(catalog?.models.map((model) => model.id) || []); const isCodexProvider = catalog?.provider === 'codex'; - const selectedCodexEffort = isCodexProvider ? getCodexEffortDisplay(value) : null; + + const recommendedOptions = (catalog?.models ?? []).map((model) => ({ + value: model.id, + groupKey: 'recommended', + searchText: `${model.id} ${model.name}`, + keywords: [model.tier ?? '', catalog?.provider ?? ''], + triggerContent: ( +
+ {model.id} + {isCodexProvider && } +
+ ), + itemContent: ( +
+ {model.id} + {model.tier === 'paid' && } + {isCodexProvider && } +
+ ), + })); + + const allModelOptions = allModels + .filter((model) => !catalogModelIds.has(model.id)) + .map((model) => ({ + value: model.id, + groupKey: 'all', + searchText: model.id, + keywords: [model.owned_by], + triggerContent: ( +
+ {model.id} + {isCodexProvider && } +
+ ), + itemContent: ( +
+ {model.id} + {isCodexProvider && } +
+ ), + })); + const hasAvailableModels = recommendedOptions.length + allModelOptions.length > 0; return (
@@ -283,96 +354,36 @@ export function FlexibleModelSelector({ {description &&

{description}

}
- + + ), + }, + ]} + options={[...recommendedOptions, ...allModelOptions]} + />
); } diff --git a/ui/src/components/copilot/config-form/model-selector.tsx b/ui/src/components/copilot/config-form/model-selector.tsx index 1f3a91f0..6d5448a9 100644 --- a/ui/src/components/copilot/config-form/model-selector.tsx +++ b/ui/src/components/copilot/config-form/model-selector.tsx @@ -4,16 +4,7 @@ */ import { Badge } from '@/components/ui/badge'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - SelectGroup, - SelectLabel, -} from '@/components/ui/select'; -import { Check } from 'lucide-react'; +import { SearchableSelect } from '@/components/ui/searchable-select'; import type { FlexibleModelSelectorProps } from './types'; import { getPlanBadgeStyle, getMultiplierDisplay } from './utils'; import { useTranslation } from 'react-i18next'; @@ -27,8 +18,6 @@ export function FlexibleModelSelector({ disabled, }: FlexibleModelSelectorProps) { const { t } = useTranslation(); - // Find current model for display - const currentModel = models.find((m) => m.id === value); return (
@@ -36,58 +25,63 @@ export function FlexibleModelSelector({ {description &&

{description}

}
- + ({ + value: model.id, + groupKey: 'models', + searchText: `${model.name || model.id} ${model.id}`, + keywords: [model.minPlan ?? '', model.preview ? 'preview' : ''], + triggerContent: ( +
+ {model.id} + {model.minPlan && ( + + {model.minPlan} + + )} +
+ ), + itemContent: ( +
+ {model.name || model.id} + {model.minPlan && ( + + {model.minPlan} + + )} + {model.multiplier !== undefined && ( + + {getMultiplierDisplay(model.multiplier)} + + )} + {model.preview && ( + + {t('componentModelSelector.preview')} + + )} +
+ ), + }))} + />
); } diff --git a/ui/src/components/ui/searchable-select.tsx b/ui/src/components/ui/searchable-select.tsx new file mode 100644 index 00000000..9da3602e --- /dev/null +++ b/ui/src/components/ui/searchable-select.tsx @@ -0,0 +1,338 @@ +import * as React from 'react'; +import { Check, ChevronsUpDown, Search } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +export interface SearchableSelectGroup { + key: string; + label?: React.ReactNode; +} + +export interface SearchableSelectOption { + value: string; + searchText: string; + itemContent: React.ReactNode; + triggerContent?: React.ReactNode; + keywords?: string[]; + groupKey?: string; + disabled?: boolean; +} + +interface SearchableSelectProps { + value?: string; + onChange: (value: string) => void; + options: SearchableSelectOption[]; + groups?: SearchableSelectGroup[]; + placeholder: string; + searchPlaceholder: string; + emptyText: string; + disabled?: boolean; + className?: string; + triggerClassName?: string; + contentClassName?: string; +} + +function normalizeSearch(value: string): string { + return value.trim().toLowerCase(); +} + +function getOptionId(listboxId: string, value: string): string { + return `${listboxId}-option-${value.replace(/[^a-z0-9_-]+/gi, '-')}`; +} + +export function SearchableSelect({ + value, + onChange, + options, + groups, + placeholder, + searchPlaceholder, + emptyText, + disabled, + className, + triggerClassName, + contentClassName, +}: SearchableSelectProps) { + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(''); + const [activeOptionValue, setActiveOptionValue] = React.useState(); + const listboxId = React.useId(); + const searchInputRef = React.useRef(null); + const optionRefs = React.useRef>({}); + + const selectedOption = React.useMemo( + () => options.find((option) => option.value === value), + [options, value] + ); + + const filteredOptions = React.useMemo(() => { + const normalizedQuery = normalizeSearch(query); + if (!normalizedQuery) return options; + + return options.filter((option) => + [option.searchText, ...(option.keywords ?? [])].some((candidate) => + normalizeSearch(candidate).includes(normalizedQuery) + ) + ); + }, [options, query]); + + const groupedOptions = React.useMemo(() => { + const knownGroups = new Map((groups ?? []).map((group) => [group.key, group])); + const ungrouped = filteredOptions.filter( + (option) => !option.groupKey || !knownGroups.has(option.groupKey) + ); + const grouped = (groups ?? []) + .map((group) => ({ + ...group, + options: filteredOptions.filter((option) => option.groupKey === group.key), + })) + .filter((group) => group.options.length > 0); + + if (ungrouped.length === 0) return grouped; + + return [{ key: '__default', options: ungrouped }, ...grouped]; + }, [filteredOptions, groups]); + + const enabledFilteredOptions = React.useMemo( + () => filteredOptions.filter((option) => !option.disabled), + [filteredOptions] + ); + + const selectedContent = selectedOption?.triggerContent ?? selectedOption?.itemContent; + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + setQuery(''); + setActiveOptionValue(undefined); + } + }; + + const focusSearchInput = () => searchInputRef.current?.focus(); + + React.useEffect(() => { + if (!open) return; + if (enabledFilteredOptions.length === 0) { + setActiveOptionValue(undefined); + return; + } + + setActiveOptionValue((currentValue) => { + if (currentValue && enabledFilteredOptions.some((option) => option.value === currentValue)) { + return currentValue; + } + + return ( + enabledFilteredOptions.find((option) => option.value === value)?.value ?? + enabledFilteredOptions[0]?.value + ); + }); + }, [enabledFilteredOptions, open, value]); + + React.useEffect(() => { + if (!open || !activeOptionValue) return; + optionRefs.current[activeOptionValue]?.scrollIntoView({ block: 'nearest' }); + }, [activeOptionValue, open]); + + const moveActiveOption = (direction: 'next' | 'previous' | 'first' | 'last') => { + if (enabledFilteredOptions.length === 0) return; + if (direction === 'first') { + setActiveOptionValue(enabledFilteredOptions[0]?.value); + return; + } + if (direction === 'last') { + setActiveOptionValue(enabledFilteredOptions.at(-1)?.value); + return; + } + + const currentIndex = enabledFilteredOptions.findIndex( + (option) => option.value === activeOptionValue + ); + const fallbackIndex = direction === 'next' ? -1 : enabledFilteredOptions.length; + const startIndex = currentIndex >= 0 ? currentIndex : fallbackIndex; + const nextIndex = + direction === 'next' + ? Math.min(startIndex + 1, enabledFilteredOptions.length - 1) + : Math.max(startIndex - 1, 0); + + setActiveOptionValue(enabledFilteredOptions[nextIndex]?.value); + }; + + const selectOption = (nextValue: string) => { + onChange(nextValue); + handleOpenChange(false); + }; + + const selectActiveOption = () => { + if (!activeOptionValue) return; + const activeOption = enabledFilteredOptions.find( + (option) => option.value === activeOptionValue + ); + if (!activeOption) return; + selectOption(activeOption.value); + }; + + return ( + + + + + { + event.preventDefault(); + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(focusSearchInput); + return; + } + setTimeout(focusSearchInput, 0); + }} + > +
+
+ + setQuery(event.target.value)} + role="combobox" + aria-label={searchPlaceholder} + aria-autocomplete="list" + aria-expanded={open} + aria-controls={listboxId} + aria-activedescendant={ + activeOptionValue ? getOptionId(listboxId, activeOptionValue) : undefined + } + onKeyDown={(event) => { + if (event.key === 'ArrowDown') { + event.preventDefault(); + moveActiveOption('next'); + return; + } + if (event.key === 'ArrowUp') { + event.preventDefault(); + moveActiveOption('previous'); + return; + } + if (event.key === 'Home') { + event.preventDefault(); + moveActiveOption('first'); + return; + } + if (event.key === 'End') { + event.preventDefault(); + moveActiveOption('last'); + return; + } + if (event.key === 'Enter') { + event.preventDefault(); + selectActiveOption(); + return; + } + if (event.key === 'Escape') { + event.preventDefault(); + handleOpenChange(false); + } + }} + placeholder={searchPlaceholder} + className="pl-8" + /> +
+
+ + + {filteredOptions.length === 0 ? ( +
{emptyText}
+ ) : ( +
+ {groupedOptions.map((group) => ( +
+ {group.label && ( +
+ {group.label} +
+ )} + {group.options.map((option) => { + const isActive = option.value === activeOptionValue; + const isSelected = option.value === value; + return ( + + ); + })} +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 31f6d592..7736850b 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -148,6 +148,10 @@ const resources = { availableModelsCount: 'Available Models ({{count}})', preview: 'Preview', }, + searchableSelect: { + searchModels: 'Search models...', + noResults: 'No results found.', + }, copilotSettings: { enableCopilot: 'Enable Copilot', enableCopilotDesc: 'Allow using GitHub Copilot subscription', @@ -1318,6 +1322,10 @@ const resources = { availableModelsCount: '可用模型({{count}})', preview: '预览', }, + searchableSelect: { + searchModels: '搜索模型...', + noResults: '未找到匹配结果。', + }, copilotSettings: { enableCopilot: '启用 Copilot', enableCopilotDesc: '允许使用 GitHub Copilot 订阅', @@ -2451,6 +2459,10 @@ const resources = { availableModelsCount: 'Mô hình khả dụng ({{count}})', preview: 'Xem trước', }, + searchableSelect: { + searchModels: 'Tìm mô hình...', + noResults: 'Không tìm thấy kết quả phù hợp.', + }, copilotSettings: { enableCopilot: 'Bật Copilot', enableCopilotDesc: 'Cho phép sử dụng đăng ký GitHub Copilot', @@ -3641,6 +3653,10 @@ const resources = { availableModelsCount: '利用可能なモデル ({{count}})', preview: 'プレビュー', }, + searchableSelect: { + searchModels: 'モデルを検索...', + noResults: '一致する結果がありません。', + }, copilotSettings: { enableCopilot: 'Copilot を有効化', enableCopilotDesc: 'GitHub Copilot サブスクリプションを利用できるようにします', diff --git a/ui/src/pages/cursor.tsx b/ui/src/pages/cursor.tsx index 2268fb3e..2c4b7679 100644 --- a/ui/src/pages/cursor.tsx +++ b/ui/src/pages/cursor.tsx @@ -35,15 +35,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Separator } from '@/components/ui/separator'; import { RawEditorSection } from '@/components/copilot/config-form/raw-editor-section'; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; +import { SearchableSelect } from '@/components/ui/searchable-select'; import { Dialog, DialogContent, @@ -159,7 +151,47 @@ function CursorModelSelector({ }) { const { t } = useTranslation(); const selectorValue = value || (allowDefaultFallback ? '__default' : ''); - const selected = models.find((model) => model.id === value); + const options = useMemo(() => { + const mappedModels = models.map((model) => ({ + value: model.id, + groupKey: 'models', + searchText: `${model.name || model.id} ${model.id}`, + keywords: [model.provider], + triggerContent: ( +
+ {model.name || model.id} + {model.provider && ( + + {model.provider} + + )} +
+ ), + itemContent: ( +
+ {model.name || model.id} + + {model.provider} + +
+ ), + })); + + if (!allowDefaultFallback) return mappedModels; + + return [ + { + value: '__default', + groupKey: 'models', + searchText: t('cursorPage.useDefaultModel'), + triggerContent: ( + {t('cursorPage.useDefaultModel')} + ), + itemContent: {t('cursorPage.useDefaultModel')}, + }, + ...mappedModels, + ]; + }, [allowDefaultFallback, models, t]); return (
@@ -167,9 +199,9 @@ function CursorModelSelector({

{description}

- + placeholder={t('cursorPage.selectModel')} + searchPlaceholder={t('searchableSelect.searchModels')} + emptyText={t('searchableSelect.noResults')} + triggerClassName="h-9" + groups={[ + { + key: 'models', + label: t('cursorPage.availableModelCount', { count: models.length }), + }, + ]} + options={options} + />
); } diff --git a/ui/tests/setup/vitest-setup.ts b/ui/tests/setup/vitest-setup.ts index e50b7023..3633d6ac 100644 --- a/ui/tests/setup/vitest-setup.ts +++ b/ui/tests/setup/vitest-setup.ts @@ -27,12 +27,13 @@ Object.defineProperty(window, 'matchMedia', { })), }); -// Mock ResizeObserver -global.ResizeObserver = vi.fn().mockImplementation(() => ({ - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), -})); +// Mock ResizeObserver with a constructible class for Radix/Floating UI usage +class ResizeObserverMock { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); +} +global.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; // Mock localStorage const localStorageMock = { diff --git a/ui/tests/unit/components/ui/searchable-select.test.tsx b/ui/tests/unit/components/ui/searchable-select.test.tsx new file mode 100644 index 00000000..4eafc880 --- /dev/null +++ b/ui/tests/unit/components/ui/searchable-select.test.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SearchableSelect } from '@/components/ui/searchable-select'; +import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils'; + +function SearchableSelectHarness() { + const [value, setValue] = useState(); + + return ( + Claude Sonnet 4, + }, + { + value: 'gpt-5.3-codex', + groupKey: 'core', + searchText: 'GPT-5.3 Codex gpt-5.3-codex', + itemContent: GPT-5.3 Codex, + }, + { + value: 'gemini-2.5-pro', + groupKey: 'other', + searchText: 'Gemini 2.5 Pro gemini-2.5-pro', + itemContent: Gemini 2.5 Pro, + }, + ]} + /> + ); +} + +describe('SearchableSelect', () => { + beforeEach(() => { + Object.defineProperty(HTMLElement.prototype, 'hasPointerCapture', { + configurable: true, + value: vi.fn(() => false), + }); + Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', { + configurable: true, + value: vi.fn(), + }); + Object.defineProperty(HTMLElement.prototype, 'releasePointerCapture', { + configurable: true, + value: vi.fn(), + }); + Object.defineProperty(Element.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }); + }); + + it('autofocuses the search input, filters options, and updates the selection', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Select model' })); + + const searchInput = await screen.findByPlaceholderText('Search models...'); + await waitFor(() => { + expect(searchInput).toHaveFocus(); + }); + expect(searchInput).toHaveAttribute('role', 'combobox'); + + await userEvent.type(searchInput, 'gpt'); + + expect(screen.getByText('GPT-5.3 Codex')).toBeInTheDocument(); + expect(screen.queryByText('Claude Sonnet 4')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('option', { name: 'GPT-5.3 Codex' })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'GPT-5.3 Codex' })).toBeInTheDocument(); + }); + }); + + it('shows the empty state when the search query has no matches', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Select model' })); + await userEvent.type(await screen.findByPlaceholderText('Search models...'), 'no-match'); + + expect(screen.getByText('No results found.')).toBeInTheDocument(); + }); + + it('supports keyboard navigation and selection from the search input', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Select model' })); + + const searchInput = await screen.findByRole('combobox', { name: 'Search models...' }); + await waitFor(() => { + expect(searchInput).toHaveFocus(); + }); + + expect(searchInput).toHaveAttribute( + 'aria-activedescendant', + expect.stringContaining('claude-sonnet-4') + ); + + await userEvent.keyboard('[ArrowDown]'); + expect(searchInput).toHaveAttribute( + 'aria-activedescendant', + expect.stringContaining('gpt-5-3-codex') + ); + + await userEvent.keyboard('[Enter]'); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'GPT-5.3 Codex' })).toBeInTheDocument(); + }); + }); + + it('opens from the trigger with arrow keys', async () => { + render(); + + await userEvent.keyboard('[Tab][ArrowDown]'); + + const searchInput = await screen.findByRole('combobox', { name: 'Search models...' }); + await waitFor(() => { + expect(searchInput).toHaveFocus(); + }); + expect(searchInput).toHaveAttribute( + 'aria-activedescendant', + expect.stringContaining('claude-sonnet-4') + ); + }); +});