mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
feat(ui): add searchable model comboboxes
This commit is contained in:
@@ -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...]
|
||||
│
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user