feat(ui): add searchable model comboboxes

This commit is contained in:
Tam Nhu Tran
2026-03-17 09:01:21 -04:00
parent a5f457e1f3
commit d056878539
11 changed files with 671 additions and 361 deletions
+2 -1
View File
@@ -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...]
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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
@@ -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 (
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className={cn('w-[320px]', className)}>
<SelectValue placeholder={resolvedPlaceholder}>
{value && (
<div className="flex items-center gap-2">
<span className="truncate">{value}</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-[400px]">
{sortedCategories.map(({ category, display, models }) => (
<SelectGroup key={category}>
<SelectLabel className="flex items-center justify-between px-2 py-1.5">
<span className={cn('font-semibold', display.color)}>
{t(`cliproxyModelCategory.${display.key}`)}
</span>
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 ml-2">
{models.length}
</Badge>
</SelectLabel>
{models.map((model) => (
<SelectItem key={model.id} value={model.id} className="pl-4">
<span className="truncate">{model.id}</span>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
<SearchableSelect
value={value || undefined}
onChange={onChange}
disabled={disabled}
placeholder={resolvedPlaceholder}
searchPlaceholder={t('searchableSelect.searchModels')}
emptyText={t('searchableSelect.noResults')}
className={cn('w-[320px]', className)}
groups={sortedCategories.map(({ category, display, models }) => ({
key: category,
label: (
<div className="flex items-center justify-between">
<span className={cn('font-semibold', display.color)}>
{t(`cliproxyModelCategory.${display.key}`)}
</span>
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 ml-2">
{models.length}
</Badge>
</div>
),
}))}
options={sortedCategories.flatMap(({ category, models }) =>
models.map((model) => ({
value: model.id,
groupKey: category,
searchText: model.id,
keywords: [category],
itemContent: <span className="truncate">{model.id}</span>,
}))
)}
/>
);
}
@@ -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 (
<Badge variant="outline" className="text-[9px] h-4 px-1">
{label}
</Badge>
);
}
function StatusBadges({
model,
brokenLabel,
deprecatedLabel,
}: {
model: Pick<ModelEntry, 'broken' | 'deprecated'>;
brokenLabel: string;
deprecatedLabel: string;
}) {
return (
<>
{model.broken && (
<Badge variant="destructive" className="text-[9px] h-4 px-1">
{brokenLabel}
</Badge>
)}
{model.deprecated && (
<Badge variant="secondary" className="text-[9px] h-4 px-1">
{deprecatedLabel}
</Badge>
)}
</>
);
}
function CodexEffortBadge({ modelId }: { modelId: string | undefined }) {
const codexEffort = getCodexEffortDisplay(modelId);
if (!codexEffort) return null;
return (
<Badge
variant={codexEffort.explicit ? 'secondary' : 'outline'}
className="text-[9px] h-4 px-1 uppercase"
>
{codexEffort.label}
</Badge>
);
}
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 <Skeleton className={cn('h-9 w-full', className)} />;
@@ -98,76 +138,69 @@ export function ProviderModelSelector({
if (!catalog || catalog.models.length === 0) {
return (
<div className={cn('text-sm text-muted-foreground py-2', className)}>
<div className={cn('py-2 text-sm text-muted-foreground', className)}>
{t('providerModelSelector.noModelsForProvider')}
</div>
);
}
const renderModelItem = (model: ModelEntry) => (
<SelectItem
key={model.id}
value={model.id}
className={cn('pl-4', model.broken && 'opacity-60', model.deprecated && 'opacity-60')}
>
<div className="flex items-center gap-2">
<span className="truncate">{model.name}</span>
{model.broken && (
<Badge variant="destructive" className="text-[9px] h-4 px-1">
{t('providerModelSelector.broken')}
</Badge>
)}
{model.deprecated && (
<Badge variant="secondary" className="text-[9px] h-4 px-1">
{t('providerModelSelector.deprecated')}
</Badge>
)}
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
</div>
</SelectItem>
);
return (
<div className={cn('space-y-2', className)}>
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="w-full">
<SelectValue placeholder={resolvedPlaceholder}>
{selectedModel && (
<div className="flex items-center gap-2">
<span className="truncate">{selectedModel.name}</span>
{selectedModel.tier === 'paid' && (
<Badge variant="outline" className="text-[9px] h-4 px-1">
{t('providerModelSelector.paid')}
</Badge>
)}
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{groupedModels.free.length > 0 && (
<SelectGroup>
<SelectLabel className="text-xs text-muted-foreground">
<SearchableSelect
value={value || undefined}
onChange={onChange}
disabled={disabled}
placeholder={resolvedPlaceholder}
searchPlaceholder={t('searchableSelect.searchModels')}
emptyText={t('searchableSelect.noResults')}
groups={[
{
key: 'free',
label: (
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.freeTier')}
</SelectLabel>
{groupedModels.free.map(renderModelItem)}
</SelectGroup>
)}
{groupedModels.paid.length > 0 && (
<SelectGroup>
<SelectLabel className="text-xs text-amber-600">
{t('providerModelSelector.paidTier')}
</SelectLabel>
{groupedModels.paid.map(renderModelItem)}
</SelectGroup>
)}
</SelectContent>
</Select>
</span>
),
},
{
key: 'paid',
label: (
<span className="text-xs text-amber-600">{t('providerModelSelector.paidTier')}</span>
),
},
]}
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: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate">{model.name}</span>
{model.tier === 'paid' && <PaidBadge label={t('providerModelSelector.paid')} />}
</div>
),
itemContent: (
<div
className={cn(
'flex min-w-0 items-center gap-2',
(model.broken || model.deprecated) && 'opacity-60'
)}
>
<span className="truncate">{model.name}</span>
<StatusBadges
model={model}
brokenLabel={t('providerModelSelector.broken')}
deprecatedLabel={t('providerModelSelector.deprecated')}
/>
</div>
),
}))}
/>
{/* Warning for broken/deprecated models */}
{selectedModel?.broken && (
<div className="flex items-start gap-2 p-2 bg-destructive/10 rounded-md text-xs text-destructive">
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
<div className="bg-destructive/10 text-destructive flex items-start gap-2 rounded-md p-2 text-xs">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<div>
<p className="font-medium">{t('providerModelSelector.modelKnownIssues')}</p>
{selectedModel.issueUrl && (
@@ -185,8 +218,8 @@ export function ProviderModelSelector({
)}
{selectedModel?.deprecated && (
<div className="flex items-start gap-2 p-2 bg-amber-500/10 rounded-md text-xs text-amber-700">
<AlertCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
<div className="flex items-start gap-2 rounded-md bg-amber-500/10 p-2 text-xs text-amber-700">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<div>
<p className="font-medium">{t('providerModelSelector.modelDeprecated')}</p>
{selectedModel.deprecationReason && (
@@ -196,7 +229,6 @@ export function ProviderModelSelector({
</div>
)}
{/* Model description */}
{selectedModel?.description && !selectedModel.broken && !selectedModel.deprecated && (
<p className="text-xs text-muted-foreground">{selectedModel.description}</p>
)}
@@ -226,27 +258,26 @@ export function ModelMappingSelector({
return (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{label}</label>
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder={t('providerModelSelector.selectModel')}>
{value && <span className="truncate font-mono text-xs">{value}</span>}
</SelectValue>
</SelectTrigger>
<SelectContent>
{catalog.models.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2">
<span className="truncate text-sm">{model.name}</span>
{model.tier === 'paid' && (
<Badge variant="outline" className="text-[9px] h-4 px-1">
{t('providerModelSelector.paid')}
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<SearchableSelect
value={value || undefined}
onChange={onChange}
disabled={disabled}
placeholder={t('providerModelSelector.selectModel')}
searchPlaceholder={t('searchableSelect.searchModels')}
emptyText={t('searchableSelect.noResults')}
triggerClassName="h-8 text-sm"
options={catalog.models.map((model) => ({
value: model.id,
searchText: `${model.name} ${model.id}`,
triggerContent: <span className="truncate font-mono text-xs">{model.id}</span>,
itemContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm">{model.name}</span>
{model.tier === 'paid' && <PaidBadge label={t('providerModelSelector.paid')} />}
</div>
),
}))}
/>
</div>
);
}
@@ -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: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{model.id}</span>
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
</div>
),
itemContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{model.id}</span>
{model.tier === 'paid' && <PaidBadge label={t('providerModelSelector.paid')} />}
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
</div>
),
}));
const allModelOptions = allModels
.filter((model) => !catalogModelIds.has(model.id))
.map((model) => ({
value: model.id,
groupKey: 'all',
searchText: model.id,
keywords: [model.owned_by],
triggerContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{model.id}</span>
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
</div>
),
itemContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{model.id}</span>
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
</div>
),
}));
const hasAvailableModels = recommendedOptions.length + allModelOptions.length > 0;
return (
<div className="space-y-1.5">
@@ -283,96 +354,36 @@ export function FlexibleModelSelector({
<label className="text-xs font-medium">{label}</label>
{description && <p className="text-[10px] text-muted-foreground">{description}</p>}
</div>
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="h-9">
<SelectValue placeholder={t('providerModelSelector.selectModel')}>
{value && (
<div className="flex items-center gap-2">
<span className="truncate font-mono text-xs">{value}</span>
{selectedCodexEffort && (
<Badge
variant={selectedCodexEffort.explicit ? 'secondary' : 'outline'}
className="text-[9px] h-4 px-1 uppercase"
>
{selectedCodexEffort.label}
</Badge>
)}
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{/* Recommended models from catalog */}
{catalog && catalog.models.length > 0 && (
<SelectGroup>
<SelectLabel className="text-xs text-primary">
{t('providerModelSelector.recommended')}
</SelectLabel>
{catalog.models.map((model) => {
const codexEffort = isCodexProvider ? getCodexEffortDisplay(model.id) : null;
return (
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2">
<span className="truncate font-mono text-xs">{model.id}</span>
{model.tier === 'paid' && (
<Badge variant="outline" className="text-[9px] h-4 px-1">
{t('providerModelSelector.paid')}
</Badge>
)}
{codexEffort && (
<Badge
variant={codexEffort.explicit ? 'secondary' : 'outline'}
className="text-[9px] h-4 px-1 uppercase"
>
{codexEffort.label}
</Badge>
)}
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
</div>
</SelectItem>
);
})}
</SelectGroup>
)}
{/* All available models (excluding already shown) */}
{allModels.length > 0 && (
<SelectGroup>
<SelectLabel className="text-xs text-muted-foreground">
<SearchableSelect
value={value || undefined}
onChange={onChange}
disabled={disabled}
placeholder={t('providerModelSelector.selectModel')}
searchPlaceholder={t('searchableSelect.searchModels')}
emptyText={
hasAvailableModels
? t('searchableSelect.noResults')
: t('providerModelSelector.noModelsAvailable')
}
triggerClassName="h-9"
groups={[
{
key: 'recommended',
label: (
<span className="text-xs text-primary">{t('providerModelSelector.recommended')}</span>
),
},
{
key: 'all',
label: (
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.allModelsCount', { count: allModels.length })}
</SelectLabel>
{allModels
.filter((m) => !catalogModelIds.has(m.id))
.map((model) => {
const codexEffort = isCodexProvider ? getCodexEffortDisplay(model.id) : null;
return (
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2">
<span className="truncate font-mono text-xs">{model.id}</span>
{codexEffort && (
<Badge
variant={codexEffort.explicit ? 'secondary' : 'outline'}
className="text-[9px] h-4 px-1 uppercase"
>
{codexEffort.label}
</Badge>
)}
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
</div>
</SelectItem>
);
})}
</SelectGroup>
)}
{/* Fallback if no models available */}
{(!catalog || catalog.models.length === 0) && allModels.length === 0 && (
<div className="py-2 px-3 text-xs text-muted-foreground">
{t('providerModelSelector.noModelsAvailable')}
</div>
)}
</SelectContent>
</Select>
</span>
),
},
]}
options={[...recommendedOptions, ...allModelOptions]}
/>
</div>
);
}
@@ -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 (
<div className="space-y-1.5">
@@ -36,58 +25,63 @@ export function FlexibleModelSelector({
<label className="text-xs font-medium">{label}</label>
{description && <p className="text-[10px] text-muted-foreground">{description}</p>}
</div>
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="h-9">
<SelectValue placeholder={t('componentModelSelector.selectModel')}>
{value && (
<div className="flex items-center gap-2">
<span className="truncate font-mono text-xs">{value}</span>
{currentModel?.minPlan && (
<Badge
variant="outline"
className={`text-[9px] px-1 py-0 h-4 ${getPlanBadgeStyle(currentModel.minPlan)}`}
>
{currentModel.minPlan}
</Badge>
)}
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-[300px]">
<SelectGroup>
<SelectLabel className="text-xs text-muted-foreground">
{t('componentModelSelector.availableModelsCount', { count: models.length })}
</SelectLabel>
{models.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2">
<span className="truncate font-mono text-xs">{model.name || model.id}</span>
{model.minPlan && (
<Badge
variant="outline"
className={`text-[9px] px-1 py-0 h-4 ${getPlanBadgeStyle(model.minPlan)}`}
>
{model.minPlan}
</Badge>
)}
{model.multiplier !== undefined && (
<span className="text-[9px] text-muted-foreground">
{getMultiplierDisplay(model.multiplier)}
</span>
)}
{model.preview && (
<Badge variant="secondary" className="text-[9px] px-1 py-0 h-4">
{t('componentModelSelector.preview')}
</Badge>
)}
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<SearchableSelect
value={value || undefined}
onChange={onChange}
disabled={disabled}
placeholder={t('componentModelSelector.selectModel')}
searchPlaceholder={t('searchableSelect.searchModels')}
emptyText={t('searchableSelect.noResults')}
triggerClassName="h-9"
groups={[
{
key: 'models',
label: t('componentModelSelector.availableModelsCount', { count: models.length }),
},
]}
options={models.map((model) => ({
value: model.id,
groupKey: 'models',
searchText: `${model.name || model.id} ${model.id}`,
keywords: [model.minPlan ?? '', model.preview ? 'preview' : ''],
triggerContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{model.id}</span>
{model.minPlan && (
<Badge
variant="outline"
className={`text-[9px] px-1 py-0 h-4 ${getPlanBadgeStyle(model.minPlan)}`}
>
{model.minPlan}
</Badge>
)}
</div>
),
itemContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{model.name || model.id}</span>
{model.minPlan && (
<Badge
variant="outline"
className={`text-[9px] px-1 py-0 h-4 ${getPlanBadgeStyle(model.minPlan)}`}
>
{model.minPlan}
</Badge>
)}
{model.multiplier !== undefined && (
<span className="text-[9px] text-muted-foreground">
{getMultiplierDisplay(model.multiplier)}
</span>
)}
{model.preview && (
<Badge variant="secondary" className="text-[9px] px-1 py-0 h-4">
{t('componentModelSelector.preview')}
</Badge>
)}
</div>
),
}))}
/>
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
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();
}
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 searchInputRef = React.useRef<HTMLInputElement>(null);
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 selectedContent = selectedOption?.triggerContent ?? selectedOption?.itemContent;
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen);
if (!nextOpen) setQuery('');
};
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
disabled={disabled}
className={cn(
'w-full justify-between font-normal',
className,
triggerClassName,
!selectedContent && 'text-muted-foreground'
)}
>
<div className="min-w-0 flex-1 text-left">
{selectedContent ?? <span>{placeholder}</span>}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className={cn('w-[var(--radix-popover-trigger-width)] p-0', contentClassName)}
onOpenAutoFocus={(event) => {
event.preventDefault();
const focusInput = () => searchInputRef.current?.focus();
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(focusInput);
return;
}
setTimeout(focusInput, 0);
}}
>
<div className="border-b p-2">
<div className="relative">
<Search className="text-muted-foreground absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
ref={searchInputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={searchPlaceholder}
className="pl-8"
/>
</div>
</div>
<ScrollArea className="max-h-72">
{filteredOptions.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">{emptyText}</div>
) : (
<div role="listbox" aria-label={placeholder} className="p-1">
{groupedOptions.map((group) => (
<div key={group.key}>
{group.label && (
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{group.label}
</div>
)}
{group.options.map((option) => {
const isSelected = option.value === value;
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={isSelected}
disabled={option.disabled}
onClick={() => {
onChange(option.value);
handleOpenChange(false);
}}
className={cn(
'hover:bg-accent hover:text-accent-foreground flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none',
'focus-visible:ring-ring focus-visible:ring-1',
isSelected && 'bg-accent text-accent-foreground',
option.disabled && 'pointer-events-none opacity-50'
)}
>
<div className="min-w-0 flex-1">{option.itemContent}</div>
{isSelected && <Check className="h-4 w-4 shrink-0" />}
</button>
);
})}
</div>
))}
</div>
)}
</ScrollArea>
</PopoverContent>
</Popover>
);
}
+16
View File
@@ -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 サブスクリプションを利用できるようにします',
+57 -53
View File
@@ -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: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{model.name || model.id}</span>
{model.provider && (
<Badge variant="outline" className="text-[9px] h-4 px-1 capitalize">
{model.provider}
</Badge>
)}
</div>
),
itemContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-xs font-mono">{model.name || model.id}</span>
<Badge variant="outline" className="text-[9px] h-4 px-1 capitalize">
{model.provider}
</Badge>
</div>
),
}));
if (!allowDefaultFallback) return mappedModels;
return [
{
value: '__default',
groupKey: 'models',
searchText: t('cursorPage.useDefaultModel'),
triggerContent: (
<span className="truncate font-mono text-xs">{t('cursorPage.useDefaultModel')}</span>
),
itemContent: <span>{t('cursorPage.useDefaultModel')}</span>,
},
...mappedModels,
];
}, [allowDefaultFallback, models, t]);
return (
<div className="space-y-1.5">
@@ -167,9 +199,9 @@ function CursorModelSelector({
<Label className="text-xs font-medium">{label}</Label>
<p className="text-[10px] text-muted-foreground">{description}</p>
</div>
<Select
value={selectorValue}
onValueChange={(nextValue) => {
<SearchableSelect
value={selectorValue || undefined}
onChange={(nextValue) => {
if (allowDefaultFallback && nextValue === '__default') {
onChange('');
return;
@@ -177,46 +209,18 @@ function CursorModelSelector({
onChange(nextValue);
}}
disabled={disabled}
>
<SelectTrigger className="h-9">
<SelectValue placeholder={t('cursorPage.selectModel')}>
{selectorValue && (
<div className="flex items-center gap-2 min-w-0">
<span className="truncate font-mono text-xs">
{allowDefaultFallback && selectorValue === '__default'
? t('cursorPage.useDefaultModel')
: selected?.name || selectorValue}
</span>
{selected?.provider && (
<Badge variant="outline" className="text-[9px] h-4 px-1 capitalize">
{selected.provider}
</Badge>
)}
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-[300px]">
<SelectGroup>
<SelectLabel className="text-xs text-muted-foreground">
{t('cursorPage.availableModelCount', { count: models.length })}
</SelectLabel>
{allowDefaultFallback && (
<SelectItem value="__default">{t('cursorPage.useDefaultModel')}</SelectItem>
)}
{models.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2 min-w-0">
<span className="truncate text-xs font-mono">{model.name || model.id}</span>
<Badge variant="outline" className="text-[9px] h-4 px-1 capitalize">
{model.provider}
</Badge>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
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}
/>
</div>
);
}
+7 -6
View File
@@ -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 = {
@@ -0,0 +1,95 @@
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<string | undefined>();
return (
<SearchableSelect
value={value}
onChange={setValue}
placeholder="Select model"
searchPlaceholder="Search models..."
emptyText="No results found."
groups={[
{ key: 'core', label: 'Core Models' },
{ key: 'other', label: 'Other Models' },
]}
options={[
{
value: 'claude-sonnet-4',
groupKey: 'core',
searchText: 'Claude Sonnet 4 claude-sonnet-4',
itemContent: <span>Claude Sonnet 4</span>,
},
{
value: 'gpt-5.3-codex',
groupKey: 'core',
searchText: 'GPT-5.3 Codex gpt-5.3-codex',
itemContent: <span>GPT-5.3 Codex</span>,
},
{
value: 'gemini-2.5-pro',
groupKey: 'other',
searchText: 'Gemini 2.5 Pro gemini-2.5-pro',
itemContent: <span>Gemini 2.5 Pro</span>,
},
]}
/>
);
}
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(<SearchableSelectHarness />);
await userEvent.click(screen.getByRole('combobox'));
const searchInput = await screen.findByPlaceholderText('Search models...');
await waitFor(() => {
expect(searchInput).toHaveFocus();
});
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('combobox')).toHaveTextContent('GPT-5.3 Codex');
});
});
it('shows the empty state when the search query has no matches', async () => {
render(<SearchableSelectHarness />);
await userEvent.click(screen.getByRole('combobox'));
await userEvent.type(await screen.findByPlaceholderText('Search models...'), 'no-match');
expect(screen.getByText('No results found.')).toBeInTheDocument();
});
});