mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 14:15:56 +00:00
feat(ui): add searchable model comboboxes
This commit is contained in:
1 parent
a5f457e1f3
commit
d056878539
11 files changed
+671
-361
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
# CCS Codebase Summary
|
# 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.
|
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
|
│ ├── button.tsx
|
||||||
│ ├── card.tsx
|
│ ├── card.tsx
|
||||||
│ ├── dialog.tsx
|
│ ├── dialog.tsx
|
||||||
|
│ ├── searchable-select.tsx # Shared searchable combobox for model pickers
|
||||||
│ ├── sidebar.tsx # Custom sidebar (674 lines)
|
│ ├── sidebar.tsx # Custom sidebar (674 lines)
|
||||||
│ └── [UI primitives...]
|
│ └── [UI primitives...]
|
||||||
│
|
│
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ Available controls:
|
|||||||
- Auth actions (auto-detect, manual import)
|
- Auth actions (auto-detect, manual import)
|
||||||
- Daemon actions (start/stop)
|
- Daemon actions (start/stop)
|
||||||
- Runtime config (port, auto-start, ghost mode)
|
- 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 editor for `~/.ccs/cursor.settings.json`
|
||||||
|
|
||||||
## Raw Settings and Unified Config Sync
|
## Raw Settings and Unified Config Sync
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# CCS Project Roadmap
|
# 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.
|
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
|
### 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.
|
- **#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
|
### Maintainability Hardening Kickoff
|
||||||
|
|
||||||
|
|||||||
@@ -5,15 +5,7 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import { SearchableSelect } from '@/components/ui/searchable-select';
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectLabel,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Cpu } from 'lucide-react';
|
import { Cpu } from 'lucide-react';
|
||||||
import type { CliproxyModelsResponse } from '@/lib/api-client';
|
import type { CliproxyModelsResponse } from '@/lib/api-client';
|
||||||
@@ -94,36 +86,37 @@ export function CategorizedModelSelector({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
<SearchableSelect
|
||||||
<SelectTrigger className={cn('w-[320px]', className)}>
|
value={value || undefined}
|
||||||
<SelectValue placeholder={resolvedPlaceholder}>
|
onChange={onChange}
|
||||||
{value && (
|
disabled={disabled}
|
||||||
<div className="flex items-center gap-2">
|
placeholder={resolvedPlaceholder}
|
||||||
<span className="truncate">{value}</span>
|
searchPlaceholder={t('searchableSelect.searchModels')}
|
||||||
</div>
|
emptyText={t('searchableSelect.noResults')}
|
||||||
)}
|
className={cn('w-[320px]', className)}
|
||||||
</SelectValue>
|
groups={sortedCategories.map(({ category, display, models }) => ({
|
||||||
</SelectTrigger>
|
key: category,
|
||||||
<SelectContent className="max-h-[400px]">
|
label: (
|
||||||
{sortedCategories.map(({ category, display, models }) => (
|
<div className="flex items-center justify-between">
|
||||||
<SelectGroup key={category}>
|
<span className={cn('font-semibold', display.color)}>
|
||||||
<SelectLabel className="flex items-center justify-between px-2 py-1.5">
|
{t(`cliproxyModelCategory.${display.key}`)}
|
||||||
<span className={cn('font-semibold', display.color)}>
|
</span>
|
||||||
{t(`cliproxyModelCategory.${display.key}`)}
|
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 ml-2">
|
||||||
</span>
|
{models.length}
|
||||||
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 ml-2">
|
</Badge>
|
||||||
{models.length}
|
</div>
|
||||||
</Badge>
|
),
|
||||||
</SelectLabel>
|
}))}
|
||||||
{models.map((model) => (
|
options={sortedCategories.flatMap(({ category, models }) =>
|
||||||
<SelectItem key={model.id} value={model.id} className="pl-4">
|
models.map((model) => ({
|
||||||
<span className="truncate">{model.id}</span>
|
value: model.id,
|
||||||
</SelectItem>
|
groupKey: category,
|
||||||
))}
|
searchText: model.id,
|
||||||
</SelectGroup>
|
keywords: [category],
|
||||||
))}
|
itemContent: <span className="truncate">{model.id}</span>,
|
||||||
</SelectContent>
|
}))
|
||||||
</Select>
|
)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,22 +5,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { AlertCircle, AlertTriangle } from 'lucide-react';
|
||||||
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 { useTranslation } from 'react-i18next';
|
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 */
|
/** Model entry from catalog */
|
||||||
export interface ModelEntry {
|
export interface ModelEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -67,6 +60,53 @@ interface ProviderModelSelectorProps {
|
|||||||
className?: string;
|
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({
|
export function ProviderModelSelector({
|
||||||
catalog,
|
catalog,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -79,18 +119,18 @@ export function ProviderModelSelector({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const resolvedPlaceholder = placeholder ?? t('providerModelSelector.selectModel');
|
const resolvedPlaceholder = placeholder ?? t('providerModelSelector.selectModel');
|
||||||
|
|
||||||
// Group models by tier
|
|
||||||
const groupedModels = useMemo(() => {
|
const groupedModels = useMemo(() => {
|
||||||
if (!catalog?.models) return { free: [], paid: [] };
|
if (!catalog?.models) return { free: [], paid: [] };
|
||||||
return {
|
return {
|
||||||
free: catalog.models.filter((m) => !m.tier || m.tier === 'free'),
|
free: catalog.models.filter((model) => !model.tier || model.tier === 'free'),
|
||||||
paid: catalog.models.filter((m) => m.tier === 'paid'),
|
paid: catalog.models.filter((model) => model.tier === 'paid'),
|
||||||
};
|
};
|
||||||
}, [catalog]);
|
}, [catalog]);
|
||||||
|
|
||||||
const selectedModel = useMemo(() => {
|
const selectedModel = useMemo(
|
||||||
return catalog?.models.find((m) => m.id === value);
|
() => catalog?.models.find((model) => model.id === value),
|
||||||
}, [catalog, value]);
|
[catalog, value]
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <Skeleton className={cn('h-9 w-full', className)} />;
|
return <Skeleton className={cn('h-9 w-full', className)} />;
|
||||||
@@ -98,76 +138,69 @@ export function ProviderModelSelector({
|
|||||||
|
|
||||||
if (!catalog || catalog.models.length === 0) {
|
if (!catalog || catalog.models.length === 0) {
|
||||||
return (
|
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')}
|
{t('providerModelSelector.noModelsForProvider')}
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className={cn('space-y-2', className)}>
|
<div className={cn('space-y-2', className)}>
|
||||||
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
<SearchableSelect
|
||||||
<SelectTrigger className="w-full">
|
value={value || undefined}
|
||||||
<SelectValue placeholder={resolvedPlaceholder}>
|
onChange={onChange}
|
||||||
{selectedModel && (
|
disabled={disabled}
|
||||||
<div className="flex items-center gap-2">
|
placeholder={resolvedPlaceholder}
|
||||||
<span className="truncate">{selectedModel.name}</span>
|
searchPlaceholder={t('searchableSelect.searchModels')}
|
||||||
{selectedModel.tier === 'paid' && (
|
emptyText={t('searchableSelect.noResults')}
|
||||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
groups={[
|
||||||
{t('providerModelSelector.paid')}
|
{
|
||||||
</Badge>
|
key: 'free',
|
||||||
)}
|
label: (
|
||||||
</div>
|
<span className="text-xs text-muted-foreground">
|
||||||
)}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{groupedModels.free.length > 0 && (
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectLabel className="text-xs text-muted-foreground">
|
|
||||||
{t('providerModelSelector.freeTier')}
|
{t('providerModelSelector.freeTier')}
|
||||||
</SelectLabel>
|
</span>
|
||||||
{groupedModels.free.map(renderModelItem)}
|
),
|
||||||
</SelectGroup>
|
},
|
||||||
)}
|
{
|
||||||
{groupedModels.paid.length > 0 && (
|
key: 'paid',
|
||||||
<SelectGroup>
|
label: (
|
||||||
<SelectLabel className="text-xs text-amber-600">
|
<span className="text-xs text-amber-600">{t('providerModelSelector.paidTier')}</span>
|
||||||
{t('providerModelSelector.paidTier')}
|
),
|
||||||
</SelectLabel>
|
},
|
||||||
{groupedModels.paid.map(renderModelItem)}
|
]}
|
||||||
</SelectGroup>
|
options={[...groupedModels.free, ...groupedModels.paid].map((model) => ({
|
||||||
)}
|
value: model.id,
|
||||||
</SelectContent>
|
groupKey: model.tier === 'paid' ? 'paid' : 'free',
|
||||||
</Select>
|
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 && (
|
{selectedModel?.broken && (
|
||||||
<div className="flex items-start gap-2 p-2 bg-destructive/10 rounded-md text-xs text-destructive">
|
<div className="bg-destructive/10 text-destructive flex items-start gap-2 rounded-md p-2 text-xs">
|
||||||
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{t('providerModelSelector.modelKnownIssues')}</p>
|
<p className="font-medium">{t('providerModelSelector.modelKnownIssues')}</p>
|
||||||
{selectedModel.issueUrl && (
|
{selectedModel.issueUrl && (
|
||||||
@@ -185,8 +218,8 @@ export function ProviderModelSelector({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedModel?.deprecated && (
|
{selectedModel?.deprecated && (
|
||||||
<div className="flex items-start gap-2 p-2 bg-amber-500/10 rounded-md text-xs text-amber-700">
|
<div className="flex items-start gap-2 rounded-md bg-amber-500/10 p-2 text-xs text-amber-700">
|
||||||
<AlertCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{t('providerModelSelector.modelDeprecated')}</p>
|
<p className="font-medium">{t('providerModelSelector.modelDeprecated')}</p>
|
||||||
{selectedModel.deprecationReason && (
|
{selectedModel.deprecationReason && (
|
||||||
@@ -196,7 +229,6 @@ export function ProviderModelSelector({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Model description */}
|
|
||||||
{selectedModel?.description && !selectedModel.broken && !selectedModel.deprecated && (
|
{selectedModel?.description && !selectedModel.broken && !selectedModel.deprecated && (
|
||||||
<p className="text-xs text-muted-foreground">{selectedModel.description}</p>
|
<p className="text-xs text-muted-foreground">{selectedModel.description}</p>
|
||||||
)}
|
)}
|
||||||
@@ -226,27 +258,26 @@ export function ModelMappingSelector({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-muted-foreground">{label}</label>
|
<label className="text-xs font-medium text-muted-foreground">{label}</label>
|
||||||
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
<SearchableSelect
|
||||||
<SelectTrigger className="h-8 text-sm">
|
value={value || undefined}
|
||||||
<SelectValue placeholder={t('providerModelSelector.selectModel')}>
|
onChange={onChange}
|
||||||
{value && <span className="truncate font-mono text-xs">{value}</span>}
|
disabled={disabled}
|
||||||
</SelectValue>
|
placeholder={t('providerModelSelector.selectModel')}
|
||||||
</SelectTrigger>
|
searchPlaceholder={t('searchableSelect.searchModels')}
|
||||||
<SelectContent>
|
emptyText={t('searchableSelect.noResults')}
|
||||||
{catalog.models.map((model) => (
|
triggerClassName="h-8 text-sm"
|
||||||
<SelectItem key={model.id} value={model.id}>
|
options={catalog.models.map((model) => ({
|
||||||
<div className="flex items-center gap-2">
|
value: model.id,
|
||||||
<span className="truncate text-sm">{model.name}</span>
|
searchText: `${model.name} ${model.id}`,
|
||||||
{model.tier === 'paid' && (
|
triggerContent: <span className="truncate font-mono text-xs">{model.id}</span>,
|
||||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
itemContent: (
|
||||||
{t('providerModelSelector.paid')}
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
</Badge>
|
<span className="truncate text-sm">{model.name}</span>
|
||||||
)}
|
{model.tier === 'paid' && <PaidBadge label={t('providerModelSelector.paid')} />}
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
),
|
||||||
))}
|
}))}
|
||||||
</SelectContent>
|
/>
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -272,10 +303,50 @@ export function FlexibleModelSelector({
|
|||||||
disabled,
|
disabled,
|
||||||
}: FlexibleModelSelectorProps) {
|
}: FlexibleModelSelectorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Combine catalog models (recommended) with all available models
|
const catalogModelIds = new Set(catalog?.models.map((model) => model.id) || []);
|
||||||
const catalogModelIds = new Set(catalog?.models.map((m) => m.id) || []);
|
|
||||||
const isCodexProvider = catalog?.provider === 'codex';
|
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 (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -283,96 +354,36 @@ export function FlexibleModelSelector({
|
|||||||
<label className="text-xs font-medium">{label}</label>
|
<label className="text-xs font-medium">{label}</label>
|
||||||
{description && <p className="text-[10px] text-muted-foreground">{description}</p>}
|
{description && <p className="text-[10px] text-muted-foreground">{description}</p>}
|
||||||
</div>
|
</div>
|
||||||
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
<SearchableSelect
|
||||||
<SelectTrigger className="h-9">
|
value={value || undefined}
|
||||||
<SelectValue placeholder={t('providerModelSelector.selectModel')}>
|
onChange={onChange}
|
||||||
{value && (
|
disabled={disabled}
|
||||||
<div className="flex items-center gap-2">
|
placeholder={t('providerModelSelector.selectModel')}
|
||||||
<span className="truncate font-mono text-xs">{value}</span>
|
searchPlaceholder={t('searchableSelect.searchModels')}
|
||||||
{selectedCodexEffort && (
|
emptyText={
|
||||||
<Badge
|
hasAvailableModels
|
||||||
variant={selectedCodexEffort.explicit ? 'secondary' : 'outline'}
|
? t('searchableSelect.noResults')
|
||||||
className="text-[9px] h-4 px-1 uppercase"
|
: t('providerModelSelector.noModelsAvailable')
|
||||||
>
|
}
|
||||||
{selectedCodexEffort.label}
|
triggerClassName="h-9"
|
||||||
</Badge>
|
groups={[
|
||||||
)}
|
{
|
||||||
</div>
|
key: 'recommended',
|
||||||
)}
|
label: (
|
||||||
</SelectValue>
|
<span className="text-xs text-primary">{t('providerModelSelector.recommended')}</span>
|
||||||
</SelectTrigger>
|
),
|
||||||
<SelectContent className="max-h-[300px]">
|
},
|
||||||
{/* Recommended models from catalog */}
|
{
|
||||||
{catalog && catalog.models.length > 0 && (
|
key: 'all',
|
||||||
<SelectGroup>
|
label: (
|
||||||
<SelectLabel className="text-xs text-primary">
|
<span className="text-xs text-muted-foreground">
|
||||||
{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">
|
|
||||||
{t('providerModelSelector.allModelsCount', { count: allModels.length })}
|
{t('providerModelSelector.allModelsCount', { count: allModels.length })}
|
||||||
</SelectLabel>
|
</span>
|
||||||
{allModels
|
),
|
||||||
.filter((m) => !catalogModelIds.has(m.id))
|
},
|
||||||
.map((model) => {
|
]}
|
||||||
const codexEffort = isCodexProvider ? getCodexEffortDisplay(model.id) : null;
|
options={[...recommendedOptions, ...allModelOptions]}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,16 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import { SearchableSelect } from '@/components/ui/searchable-select';
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
SelectGroup,
|
|
||||||
SelectLabel,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { Check } from 'lucide-react';
|
|
||||||
import type { FlexibleModelSelectorProps } from './types';
|
import type { FlexibleModelSelectorProps } from './types';
|
||||||
import { getPlanBadgeStyle, getMultiplierDisplay } from './utils';
|
import { getPlanBadgeStyle, getMultiplierDisplay } from './utils';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -27,8 +18,6 @@ export function FlexibleModelSelector({
|
|||||||
disabled,
|
disabled,
|
||||||
}: FlexibleModelSelectorProps) {
|
}: FlexibleModelSelectorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Find current model for display
|
|
||||||
const currentModel = models.find((m) => m.id === value);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -36,58 +25,63 @@ export function FlexibleModelSelector({
|
|||||||
<label className="text-xs font-medium">{label}</label>
|
<label className="text-xs font-medium">{label}</label>
|
||||||
{description && <p className="text-[10px] text-muted-foreground">{description}</p>}
|
{description && <p className="text-[10px] text-muted-foreground">{description}</p>}
|
||||||
</div>
|
</div>
|
||||||
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
<SearchableSelect
|
||||||
<SelectTrigger className="h-9">
|
value={value || undefined}
|
||||||
<SelectValue placeholder={t('componentModelSelector.selectModel')}>
|
onChange={onChange}
|
||||||
{value && (
|
disabled={disabled}
|
||||||
<div className="flex items-center gap-2">
|
placeholder={t('componentModelSelector.selectModel')}
|
||||||
<span className="truncate font-mono text-xs">{value}</span>
|
searchPlaceholder={t('searchableSelect.searchModels')}
|
||||||
{currentModel?.minPlan && (
|
emptyText={t('searchableSelect.noResults')}
|
||||||
<Badge
|
triggerClassName="h-9"
|
||||||
variant="outline"
|
groups={[
|
||||||
className={`text-[9px] px-1 py-0 h-4 ${getPlanBadgeStyle(currentModel.minPlan)}`}
|
{
|
||||||
>
|
key: 'models',
|
||||||
{currentModel.minPlan}
|
label: t('componentModelSelector.availableModelsCount', { count: models.length }),
|
||||||
</Badge>
|
},
|
||||||
)}
|
]}
|
||||||
</div>
|
options={models.map((model) => ({
|
||||||
)}
|
value: model.id,
|
||||||
</SelectValue>
|
groupKey: 'models',
|
||||||
</SelectTrigger>
|
searchText: `${model.name || model.id} ${model.id}`,
|
||||||
<SelectContent className="max-h-[300px]">
|
keywords: [model.minPlan ?? '', model.preview ? 'preview' : ''],
|
||||||
<SelectGroup>
|
triggerContent: (
|
||||||
<SelectLabel className="text-xs text-muted-foreground">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
{t('componentModelSelector.availableModelsCount', { count: models.length })}
|
<span className="truncate font-mono text-xs">{model.id}</span>
|
||||||
</SelectLabel>
|
{model.minPlan && (
|
||||||
{models.map((model) => (
|
<Badge
|
||||||
<SelectItem key={model.id} value={model.id}>
|
variant="outline"
|
||||||
<div className="flex items-center gap-2">
|
className={`text-[9px] px-1 py-0 h-4 ${getPlanBadgeStyle(model.minPlan)}`}
|
||||||
<span className="truncate font-mono text-xs">{model.name || model.id}</span>
|
>
|
||||||
{model.minPlan && (
|
{model.minPlan}
|
||||||
<Badge
|
</Badge>
|
||||||
variant="outline"
|
)}
|
||||||
className={`text-[9px] px-1 py-0 h-4 ${getPlanBadgeStyle(model.minPlan)}`}
|
</div>
|
||||||
>
|
),
|
||||||
{model.minPlan}
|
itemContent: (
|
||||||
</Badge>
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
)}
|
<span className="truncate font-mono text-xs">{model.name || model.id}</span>
|
||||||
{model.multiplier !== undefined && (
|
{model.minPlan && (
|
||||||
<span className="text-[9px] text-muted-foreground">
|
<Badge
|
||||||
{getMultiplierDisplay(model.multiplier)}
|
variant="outline"
|
||||||
</span>
|
className={`text-[9px] px-1 py-0 h-4 ${getPlanBadgeStyle(model.minPlan)}`}
|
||||||
)}
|
>
|
||||||
{model.preview && (
|
{model.minPlan}
|
||||||
<Badge variant="secondary" className="text-[9px] px-1 py-0 h-4">
|
</Badge>
|
||||||
{t('componentModelSelector.preview')}
|
)}
|
||||||
</Badge>
|
{model.multiplier !== undefined && (
|
||||||
)}
|
<span className="text-[9px] text-muted-foreground">
|
||||||
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
|
{getMultiplierDisplay(model.multiplier)}
|
||||||
</div>
|
</span>
|
||||||
</SelectItem>
|
)}
|
||||||
))}
|
{model.preview && (
|
||||||
</SelectGroup>
|
<Badge variant="secondary" className="text-[9px] px-1 py-0 h-4">
|
||||||
</SelectContent>
|
{t('componentModelSelector.preview')}
|
||||||
</Select>
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
</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}})',
|
availableModelsCount: 'Available Models ({{count}})',
|
||||||
preview: 'Preview',
|
preview: 'Preview',
|
||||||
},
|
},
|
||||||
|
searchableSelect: {
|
||||||
|
searchModels: 'Search models...',
|
||||||
|
noResults: 'No results found.',
|
||||||
|
},
|
||||||
copilotSettings: {
|
copilotSettings: {
|
||||||
enableCopilot: 'Enable Copilot',
|
enableCopilot: 'Enable Copilot',
|
||||||
enableCopilotDesc: 'Allow using GitHub Copilot subscription',
|
enableCopilotDesc: 'Allow using GitHub Copilot subscription',
|
||||||
@@ -1318,6 +1322,10 @@ const resources = {
|
|||||||
availableModelsCount: '可用模型({{count}})',
|
availableModelsCount: '可用模型({{count}})',
|
||||||
preview: '预览',
|
preview: '预览',
|
||||||
},
|
},
|
||||||
|
searchableSelect: {
|
||||||
|
searchModels: '搜索模型...',
|
||||||
|
noResults: '未找到匹配结果。',
|
||||||
|
},
|
||||||
copilotSettings: {
|
copilotSettings: {
|
||||||
enableCopilot: '启用 Copilot',
|
enableCopilot: '启用 Copilot',
|
||||||
enableCopilotDesc: '允许使用 GitHub Copilot 订阅',
|
enableCopilotDesc: '允许使用 GitHub Copilot 订阅',
|
||||||
@@ -2451,6 +2459,10 @@ const resources = {
|
|||||||
availableModelsCount: 'Mô hình khả dụng ({{count}})',
|
availableModelsCount: 'Mô hình khả dụng ({{count}})',
|
||||||
preview: 'Xem trước',
|
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: {
|
copilotSettings: {
|
||||||
enableCopilot: 'Bật Copilot',
|
enableCopilot: 'Bật Copilot',
|
||||||
enableCopilotDesc: 'Cho phép sử dụng đăng ký GitHub Copilot',
|
enableCopilotDesc: 'Cho phép sử dụng đăng ký GitHub Copilot',
|
||||||
@@ -3641,6 +3653,10 @@ const resources = {
|
|||||||
availableModelsCount: '利用可能なモデル ({{count}})',
|
availableModelsCount: '利用可能なモデル ({{count}})',
|
||||||
preview: 'プレビュー',
|
preview: 'プレビュー',
|
||||||
},
|
},
|
||||||
|
searchableSelect: {
|
||||||
|
searchModels: 'モデルを検索...',
|
||||||
|
noResults: '一致する結果がありません。',
|
||||||
|
},
|
||||||
copilotSettings: {
|
copilotSettings: {
|
||||||
enableCopilot: 'Copilot を有効化',
|
enableCopilot: 'Copilot を有効化',
|
||||||
enableCopilotDesc: 'GitHub 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 { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { RawEditorSection } from '@/components/copilot/config-form/raw-editor-section';
|
import { RawEditorSection } from '@/components/copilot/config-form/raw-editor-section';
|
||||||
import {
|
import { SearchableSelect } from '@/components/ui/searchable-select';
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectLabel,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -159,7 +151,47 @@ function CursorModelSelector({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const selectorValue = value || (allowDefaultFallback ? '__default' : '');
|
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 (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -167,9 +199,9 @@ function CursorModelSelector({
|
|||||||
<Label className="text-xs font-medium">{label}</Label>
|
<Label className="text-xs font-medium">{label}</Label>
|
||||||
<p className="text-[10px] text-muted-foreground">{description}</p>
|
<p className="text-[10px] text-muted-foreground">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<SearchableSelect
|
||||||
value={selectorValue}
|
value={selectorValue || undefined}
|
||||||
onValueChange={(nextValue) => {
|
onChange={(nextValue) => {
|
||||||
if (allowDefaultFallback && nextValue === '__default') {
|
if (allowDefaultFallback && nextValue === '__default') {
|
||||||
onChange('');
|
onChange('');
|
||||||
return;
|
return;
|
||||||
@@ -177,46 +209,18 @@ function CursorModelSelector({
|
|||||||
onChange(nextValue);
|
onChange(nextValue);
|
||||||
}}
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
placeholder={t('cursorPage.selectModel')}
|
||||||
<SelectTrigger className="h-9">
|
searchPlaceholder={t('searchableSelect.searchModels')}
|
||||||
<SelectValue placeholder={t('cursorPage.selectModel')}>
|
emptyText={t('searchableSelect.noResults')}
|
||||||
{selectorValue && (
|
triggerClassName="h-9"
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
groups={[
|
||||||
<span className="truncate font-mono text-xs">
|
{
|
||||||
{allowDefaultFallback && selectorValue === '__default'
|
key: 'models',
|
||||||
? t('cursorPage.useDefaultModel')
|
label: t('cursorPage.availableModelCount', { count: models.length }),
|
||||||
: selected?.name || selectorValue}
|
},
|
||||||
</span>
|
]}
|
||||||
{selected?.provider && (
|
options={options}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,12 +27,13 @@ Object.defineProperty(window, 'matchMedia', {
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock ResizeObserver
|
// Mock ResizeObserver with a constructible class for Radix/Floating UI usage
|
||||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
class ResizeObserverMock {
|
||||||
observe: vi.fn(),
|
observe = vi.fn();
|
||||||
unobserve: vi.fn(),
|
unobserve = vi.fn();
|
||||||
disconnect: vi.fn(),
|
disconnect = vi.fn();
|
||||||
}));
|
}
|
||||||
|
global.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver;
|
||||||
|
|
||||||
// Mock localStorage
|
// Mock localStorage
|
||||||
const localStorageMock = {
|
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