Improve model picker search and shared BYOK catalogs (#3262) (#3278)

This commit is contained in:
Amy 2026-05-29 15:07:40 +08:00 committed by GitHub
parent 755d84e64c
commit 937946c6fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 708 additions and 108 deletions

View file

@ -96,6 +96,7 @@ import type {
DesignSystemSummary, DesignSystemSummary,
Project, Project,
ProjectTemplate, ProjectTemplate,
ProviderModelOption,
PromptTemplateSummary, PromptTemplateSummary,
SkillSummary, SkillSummary,
} from './types'; } from './types';
@ -232,6 +233,9 @@ function AppInner() {
const [appVersionInfo, setAppVersionInfo] = useState<AppVersionInfo | null>( const [appVersionInfo, setAppVersionInfo] = useState<AppVersionInfo | null>(
null, null,
); );
const [providerModelsCache, setProviderModelsCache] = useState<
Record<string, ProviderModelOption[]>
>({});
const [daemonMediaProviders, setDaemonMediaProviders] = useState< const [daemonMediaProviders, setDaemonMediaProviders] = useState<
AppConfig['mediaProviders'] | null AppConfig['mediaProviders'] | null
>(null); >(null);
@ -1495,6 +1499,8 @@ function AppInner() {
defaultDesignSystemId={config.designSystemId} defaultDesignSystemId={config.designSystemId}
agents={agents} agents={agents}
config={config} config={config}
providerModelsCache={providerModelsCache}
onProviderModelsCacheChange={setProviderModelsCache}
integrationInitialTab={integrationInitialTab} integrationInitialTab={integrationInitialTab}
composioConfigLoading={composioConfigLoading} composioConfigLoading={composioConfigLoading}
daemonLive={daemonLive} daemonLive={daemonLive}
@ -1618,6 +1624,8 @@ function AppInner() {
onReloadMediaProviders={reloadMediaProvidersFromDaemon} onReloadMediaProviders={reloadMediaProvidersFromDaemon}
onSkillsChanged={handleSkillsChanged} onSkillsChanged={handleSkillsChanged}
onDesignSystemsChanged={handleDesignSystemsChanged} onDesignSystemsChanged={handleDesignSystemsChanged}
providerModelsCache={providerModelsCache}
onProviderModelsCacheChange={setProviderModelsCache}
/> />
) : null} ) : null}
<MemoryToast onOpenMemory={() => openSettings('memory')} /> <MemoryToast onOpenMemory={() => openSettings('memory')} />

View file

@ -13,8 +13,10 @@ import {
useMemo, useMemo,
useRef, useRef,
useState, useState,
type Dispatch,
type KeyboardEvent as ReactKeyboardEvent, type KeyboardEvent as ReactKeyboardEvent,
type ReactNode, type ReactNode,
type SetStateAction,
} from 'react'; } from 'react';
import { import {
defaultScenarioPluginIdForProjectMetadata, defaultScenarioPluginIdForProjectMetadata,
@ -227,6 +229,8 @@ interface Props {
// top-bar `InlineModelSwitcher` can render the active mode/agent/model // top-bar `InlineModelSwitcher` can render the active mode/agent/model
// and persist changes through the same callbacks the project view uses. // and persist changes through the same callbacks the project view uses.
config: AppConfig; config: AppConfig;
providerModelsCache?: Record<string, ProviderModelOption[]>;
onProviderModelsCacheChange?: Dispatch<SetStateAction<Record<string, ProviderModelOption[]>>>;
agents: AgentInfo[]; agents: AgentInfo[];
daemonLive: boolean; daemonLive: boolean;
onModeChange: (mode: ExecMode) => void; onModeChange: (mode: ExecMode) => void;
@ -353,6 +357,8 @@ export function EntryShell({
designSystemsLoading = false, designSystemsLoading = false,
projectsLoading = false, projectsLoading = false,
config, config,
providerModelsCache: sharedProviderModelsCache,
onProviderModelsCacheChange,
agents, agents,
daemonLive, daemonLive,
onModeChange, onModeChange,
@ -579,6 +585,7 @@ export function EntryShell({
<span className="entry-discord-badge__label">Join Discord</span> <span className="entry-discord-badge__label">Join Discord</span>
</a> </a>
<InlineModelSwitcher <InlineModelSwitcher
providerModelsCache={sharedProviderModelsCache}
config={config} config={config}
agents={agents} agents={agents}
daemonLive={daemonLive} daemonLive={daemonLive}
@ -748,6 +755,8 @@ export function EntryShell({
function OnboardingView({ function OnboardingView({
config, config,
providerModelsCache: sharedProviderModelsCache,
onProviderModelsCacheChange,
agents, agents,
daemonLive, daemonLive,
onModeChange, onModeChange,
@ -761,6 +770,8 @@ function OnboardingView({
onFinish, onFinish,
}: { }: {
config: AppConfig; config: AppConfig;
providerModelsCache?: Record<string, ProviderModelOption[]>;
onProviderModelsCacheChange?: Dispatch<SetStateAction<Record<string, ProviderModelOption[]>>>;
agents: AgentInfo[]; agents: AgentInfo[];
daemonLive: boolean; daemonLive: boolean;
onModeChange: (mode: ExecMode) => void; onModeChange: (mode: ExecMode) => void;
@ -808,9 +819,11 @@ function OnboardingView({
| { status: 'running'; inputKey: string } | { status: 'running'; inputKey: string }
| { status: 'done'; inputKey: string; result: ProviderModelsResponse } | { status: 'done'; inputKey: string; result: ProviderModelsResponse }
>({ status: 'idle' }); >({ status: 'idle' });
const [providerModelsCache, setProviderModelsCache] = useState< const [localProviderModelsCache, setLocalProviderModelsCache] = useState<
Record<string, ProviderModelOption[]> Record<string, ProviderModelOption[]>
>({}); >({});
const providerModelsCache = sharedProviderModelsCache ?? localProviderModelsCache;
const setProviderModelsCache = onProviderModelsCacheChange ?? setLocalProviderModelsCache;
const [profile, setProfile] = useState({ const [profile, setProfile] = useState({
role: '', role: '',
orgSize: '', orgSize: '',

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react'; import { useCallback, useEffect, useState, type Dispatch, type ReactNode, type SetStateAction } from 'react';
import type { DesignSystemGenerateSnapshot } from './DesignSystemFlow'; import type { DesignSystemGenerateSnapshot } from './DesignSystemFlow';
import type { import type {
ConnectorDetail, ConnectorDetail,
@ -22,6 +22,7 @@ import type {
ProjectMetadata, ProjectMetadata,
ProjectTemplate, ProjectTemplate,
PromptTemplateSummary, PromptTemplateSummary,
ProviderModelOption,
SkillSummary, SkillSummary,
} from '../types'; } from '../types';
// `EntryShell` owns the redesigned home layout (left rail + centered // `EntryShell` owns the redesigned home layout (left rail + centered
@ -60,6 +61,8 @@ interface Props {
// sticky top-bar can expose the active CLI/BYOK + model and persist // sticky top-bar can expose the active CLI/BYOK + model and persist
// changes through the same channels as the project view. // changes through the same channels as the project view.
config: AppConfig; config: AppConfig;
providerModelsCache?: Record<string, ProviderModelOption[]>;
onProviderModelsCacheChange?: Dispatch<SetStateAction<Record<string, ProviderModelOption[]>>>;
integrationInitialTab?: IntegrationTab; integrationInitialTab?: IntegrationTab;
composioConfigLoading?: boolean; composioConfigLoading?: boolean;
daemonLive: boolean; daemonLive: boolean;
@ -255,6 +258,8 @@ export function EntryView({
defaultDesignSystemId, defaultDesignSystemId,
agents, agents,
config, config,
providerModelsCache,
onProviderModelsCacheChange,
integrationInitialTab, integrationInitialTab,
composioConfigLoading = false, composioConfigLoading = false,
daemonLive, daemonLive,
@ -355,6 +360,8 @@ export function EntryView({
designSystemsLoading={designSystemsLoading} designSystemsLoading={designSystemsLoading}
projectsLoading={projectsLoading} projectsLoading={projectsLoading}
config={config} config={config}
providerModelsCache={providerModelsCache}
onProviderModelsCacheChange={onProviderModelsCacheChange}
agents={agents} agents={agents}
daemonLive={daemonLive} daemonLive={daemonLive}
onModeChange={onModeChange} onModeChange={onModeChange}

View file

@ -17,7 +17,7 @@ import {
startVelaLogin, startVelaLogin,
type VelaLoginStatus, type VelaLoginStatus,
} from '../providers/daemon'; } from '../providers/daemon';
import type { AgentInfo, ApiProtocol, AppConfig, ExecMode } from '../types'; import type { AgentInfo, ApiProtocol, AppConfig, ExecMode, ProviderModelOption } from '../types';
import { apiProtocolLabel } from '../utils/apiProtocol'; import { apiProtocolLabel } from '../utils/apiProtocol';
import { AgentIcon } from './AgentIcon'; import { AgentIcon } from './AgentIcon';
import { Icon } from './Icon'; import { Icon } from './Icon';
@ -30,7 +30,7 @@ import {
notifyAmrLoginStatusChanged, notifyAmrLoginStatusChanged,
} from './amrLoginPolling'; } from './amrLoginPolling';
import { normalizeAgentModelChoice } from './agentModelSelection'; import { normalizeAgentModelChoice } from './agentModelSelection';
import { renderModelOptions } from './modelOptions'; import { SearchableModelSelect } from './modelOptions';
interface Props { interface Props {
config: AppConfig; config: AppConfig;
@ -44,6 +44,7 @@ interface Props {
) => void; ) => void;
onApiProtocolChange: (protocol: ApiProtocol) => void; onApiProtocolChange: (protocol: ApiProtocol) => void;
onApiModelChange: (model: string) => void; onApiModelChange: (model: string) => void;
providerModelsCache?: Record<string, ProviderModelOption[]>;
onOpenSettings: ( onOpenSettings: (
section?: section?:
| 'execution' | 'execution'
@ -108,6 +109,7 @@ export function InlineModelSwitcher({
onAgentModelChange, onAgentModelChange,
onApiProtocolChange, onApiProtocolChange,
onApiModelChange, onApiModelChange,
providerModelsCache,
onOpenSettings, onOpenSettings,
}: Props) { }: Props) {
const t = useT(); const t = useT();
@ -348,6 +350,12 @@ export function InlineModelSwitcher({
: null; : null;
const apiProtocol = config.apiProtocol ?? 'anthropic'; const apiProtocol = config.apiProtocol ?? 'anthropic';
const providerModelsInputKey = [
apiProtocol,
config.baseUrl.trim().replace(/\/+$/, ''),
config.apiKey.trim(),
config.apiVersion?.trim() ?? '',
].join('\n');
const providerForProtocol = useMemo( const providerForProtocol = useMemo(
() => () =>
KNOWN_PROVIDERS.find( KNOWN_PROVIDERS.find(
@ -359,7 +367,18 @@ export function InlineModelSwitcher({
) ?? KNOWN_PROVIDERS.find((p) => p.protocol === apiProtocol), ) ?? KNOWN_PROVIDERS.find((p) => p.protocol === apiProtocol),
[apiProtocol, config.apiProviderBaseUrl], [apiProtocol, config.apiProviderBaseUrl],
); );
const apiModelOptions = providerForProtocol?.models ?? []; const fetchedProviderModels = providerModelsCache?.[providerModelsInputKey] ?? [];
const apiModelOptions = useMemo(() => {
const discovered = fetchedProviderModels.map((model) => model.id);
const staticOptions = providerForProtocol?.models ?? [];
const merged = new Set<string>([...discovered, ...staticOptions]);
if (config.model.trim()) merged.add(config.model.trim());
return Array.from(merged);
}, [config.model, fetchedProviderModels, providerForProtocol?.models]);
const apiModelChoices = useMemo(
() => apiModelOptions.map((id) => ({ id, label: id })),
[apiModelOptions],
);
// Chip text — keep it tight so the pill doesn't wrap on small viewports. // Chip text — keep it tight so the pill doesn't wrap on small viewports.
// CLI: "Claude · Sonnet 4.5"; BYOK: "Anthropic · sonnet-4.5". // CLI: "Claude · Sonnet 4.5"; BYOK: "Anthropic · sonnet-4.5".
@ -618,25 +637,33 @@ export function InlineModelSwitcher({
<span className="inline-switcher__label"> <span className="inline-switcher__label">
{t('inlineSwitcher.modelLabel')} {t('inlineSwitcher.modelLabel')}
</span> </span>
<select <SearchableModelSelect
className="inline-switcher__select" className="inline-switcher__select"
data-testid="inline-model-switcher-agent-model" data-testid="inline-model-switcher-agent-model"
searchInputTestId="inline-model-switcher-agent-model-search"
popoverTestId="inline-model-switcher-agent-model-popover"
searchPlaceholder={t('designs.searchPlaceholder')}
aria-label={t('inlineSwitcher.modelLabel')}
models={currentAgent.models}
value={currentModelId ?? ''} value={currentModelId ?? ''}
onChange={(e) => onChange={(nextValue) =>
onAgentModelChange?.(currentAgent.id, { onAgentModelChange?.(currentAgent.id, {
model: e.target.value, model: nextValue,
}) })
} }
> additionalOptions={
{renderModelOptions(currentAgent.models)} currentAgent.id !== 'amr' &&
{currentAgent.id !== 'amr' && currentModelId &&
currentModelId && !currentAgent.models.some((m) => m.id === currentModelId)
!currentAgent.models.some((m) => m.id === currentModelId) ? ( ? [
<option value={currentModelId}> {
{currentModelId} {t('inlineSwitcher.customSuffix')} value: currentModelId,
</option> label: `${currentModelId} ${t('inlineSwitcher.customSuffix')}`,
) : null} },
</select> ]
: undefined
}
/>
</div> </div>
) : null} ) : null}
</> </>
@ -674,24 +701,27 @@ export function InlineModelSwitcher({
{t('inlineSwitcher.modelLabel')} {t('inlineSwitcher.modelLabel')}
</span> </span>
{apiModelOptions.length > 0 ? ( {apiModelOptions.length > 0 ? (
<select <SearchableModelSelect
className="inline-switcher__select" className="inline-switcher__select"
data-testid="inline-model-switcher-api-model" data-testid="inline-model-switcher-api-model"
searchInputTestId="inline-model-switcher-api-model-search"
popoverTestId="inline-model-switcher-api-model-popover"
searchPlaceholder={t('designs.searchPlaceholder')}
aria-label={t('inlineSwitcher.modelLabel')}
models={apiModelChoices}
value={config.model} value={config.model}
onChange={(e) => onApiModelChange?.(e.target.value)} onChange={(nextValue) => onApiModelChange?.(nextValue)}
> additionalOptions={
{apiModelOptions.map((id) => ( config.model && !apiModelOptions.includes(config.model)
<option key={id} value={id}> ? [
{id} {
</option> value: config.model,
))} label: `${config.model} ${t('inlineSwitcher.customSuffix')}`,
{config.model && },
!apiModelOptions.includes(config.model) ? ( ]
<option value={config.model}> : undefined
{config.model} {t('inlineSwitcher.customSuffix')} }
</option> />
) : null}
</select>
) : ( ) : (
<span className="inline-switcher__hint"> <span className="inline-switcher__hint">
{t('inlineSwitcher.openSettingsForModel')} {t('inlineSwitcher.openSettingsForModel')}

View file

@ -36,7 +36,7 @@ import { ExportDiagnosticsRow } from './ExportDiagnosticsButton';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { import {
CUSTOM_MODEL_SENTINEL, CUSTOM_MODEL_SENTINEL,
renderModelOptions, SearchableModelSelect,
} from './modelOptions'; } from './modelOptions';
import { import {
DEFAULT_NOTIFICATIONS, DEFAULT_NOTIFICATIONS,
@ -204,6 +204,8 @@ interface Props {
onSkillsChanged?: (affectedSkillId?: string) => void; onSkillsChanged?: (affectedSkillId?: string) => void;
/** Same channel for design-system registry mutations. */ /** Same channel for design-system registry mutations. */
onDesignSystemsChanged?: (affectedDesignSystemId?: string) => void; onDesignSystemsChanged?: (affectedDesignSystemId?: string) => void;
providerModelsCache?: Record<string, ProviderModelOption[]>;
onProviderModelsCacheChange?: Dispatch<SetStateAction<Record<string, ProviderModelOption[]>>>;
} }
export interface AgentRefreshOptions { export interface AgentRefreshOptions {
@ -835,6 +837,8 @@ export function SettingsDialog({
onReloadMediaProviders, onReloadMediaProviders,
onSkillsChanged, onSkillsChanged,
onDesignSystemsChanged, onDesignSystemsChanged,
providerModelsCache: sharedProviderModelsCache,
onProviderModelsCacheChange,
}: Props) { }: Props) {
const { t, locale, setLocale } = useI18n(); const { t, locale, setLocale } = useI18n();
const analytics = useAnalytics(); const analytics = useAnalytics();
@ -945,9 +949,11 @@ export function SettingsDialog({
initial.apiVersion ?? '', initial.apiVersion ?? '',
); );
}); });
const [providerModelsCache, setProviderModelsCache] = useState< const [localProviderModelsCache, setLocalProviderModelsCache] = useState<
Record<string, ProviderModelOption[]> Record<string, ProviderModelOption[]>
>({}); >({});
const providerModelsCache = sharedProviderModelsCache ?? localProviderModelsCache;
const setProviderModelsCache = onProviderModelsCacheChange ?? setLocalProviderModelsCache;
const agentTestAbortRef = useRef<AbortController | null>(null); const agentTestAbortRef = useRef<AbortController | null>(null);
const providerTestAbortRef = useRef<AbortController | null>(null); const providerTestAbortRef = useRef<AbortController | null>(null);
const providerModelsAbortRef = useRef<AbortController | null>(null); const providerModelsAbortRef = useRef<AbortController | null>(null);
@ -959,7 +965,7 @@ export function SettingsDialog({
const providerAutoTestKeyRef = useRef<string | null>(null); const providerAutoTestKeyRef = useRef<string | null>(null);
const apiKeyInputRef = useRef<HTMLInputElement | null>(null); const apiKeyInputRef = useRef<HTMLInputElement | null>(null);
const baseUrlInputRef = useRef<HTMLInputElement | null>(null); const baseUrlInputRef = useRef<HTMLInputElement | null>(null);
const modelSelectRef = useRef<HTMLSelectElement | null>(null); const modelSelectRef = useRef<HTMLButtonElement | null>(null);
const customModelInputRef = useRef<HTMLInputElement | null>(null); const customModelInputRef = useRef<HTMLInputElement | null>(null);
const focusByokRequiredFieldAfterProtocolSwitchRef = useRef(false); const focusByokRequiredFieldAfterProtocolSwitchRef = useRef(false);
const [apiModelCustomEditing, setApiModelCustomEditing] = useState(false); const [apiModelCustomEditing, setApiModelCustomEditing] = useState(false);
@ -2139,10 +2145,16 @@ export function SettingsDialog({
</span> </span>
</span> </span>
<div className="agent-model-select-wrap"> <div className="agent-model-select-wrap">
<select <SearchableModelSelect
className="inline-switcher__select settings-model-select"
value={selectValue} value={selectValue}
onChange={(e) => { aria-label={t('settings.modelPicker')}
if (e.target.value === CUSTOM_MODEL_SENTINEL) { searchPlaceholder={t('designs.searchPlaceholder')}
searchInputTestId={`settings-agent-model-search-${selected.id}`}
popoverTestId={`settings-agent-model-popover-${selected.id}`}
models={selected.models!}
onChange={(nextValue) => {
if (nextValue === CUSTOM_MODEL_SENTINEL) {
setAgentCustomModelIds((prev) => { setAgentCustomModelIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.add(selected.id); next.add(selected.id);
@ -2156,21 +2168,19 @@ export function SettingsDialog({
next.delete(selected.id); next.delete(selected.id);
return next; return next;
}); });
setChoice({ model: e.target.value }); setChoice({ model: nextValue });
} }
}} }}
> additionalOptions={
{renderModelOptions(selected.models!)} allowCustomModel
{allowCustomModel ? ( ? [
<option value={CUSTOM_MODEL_SENTINEL}> {
{t('settings.modelCustom')} value: CUSTOM_MODEL_SENTINEL,
</option> label: t('settings.modelCustom'),
) : null} },
</select> ]
<Icon : undefined
name="chevron-down" }
size={12}
className="agent-model-select-chevron"
/> />
</div> </div>
</label> </label>
@ -3370,13 +3380,21 @@ export function SettingsDialog({
* *
</span> </span>
</span> </span>
<select <SearchableModelSelect
ref={modelSelectRef} ref={modelSelectRef}
className="inline-switcher__select settings-model-select settings-model-select--byok"
aria-label={ aria-label={
apiProtocol === 'azure' apiProtocol === 'azure'
? t('settings.azureDeploymentModel') ? t('settings.azureDeploymentModel')
: t('settings.model') : t('settings.model')
} }
searchPlaceholder={t('designs.searchPlaceholder')}
searchInputTestId="settings-byok-model-search"
popoverTestId="settings-byok-model-popover"
models={apiModelOptions.map((m) => ({
id: m.id,
label: apiModelOptionLabel(m),
}))}
value={apiModelSelectValue} value={apiModelSelectValue}
onFocus={() => { onFocus={() => {
const byokProviderId = byokProtocolToTracking(apiProtocol); const byokProviderId = byokProtocolToTracking(apiProtocol);
@ -3390,21 +3408,22 @@ export function SettingsDialog({
}); });
} }
}} }}
onChange={(e) => { onChange={(nextValue) => {
if (e.target.value === CUSTOM_MODEL_SENTINEL) { if (nextValue === CUSTOM_MODEL_SENTINEL) {
setApiModelCustomEditing(true); setApiModelCustomEditing(true);
updateApiConfig({ model: '' }); updateApiConfig({ model: '' });
} else { } else {
setApiModelCustomEditing(false); setApiModelCustomEditing(false);
updateApiConfig({ model: e.target.value }); updateApiConfig({ model: nextValue });
} }
}} }}
> additionalOptions={[
{apiModelOptions.map((m) => ( {
<option value={m.id} key={m.id}>{apiModelOptionLabel(m)}</option> value: CUSTOM_MODEL_SENTINEL,
))} label: t('settings.modelCustom'),
<option value={CUSTOM_MODEL_SENTINEL}>{t('settings.modelCustom')}</option> },
</select> ]}
/>
{loadedAccountModelCount > 0 ? ( {loadedAccountModelCount > 0 ? (
<span className="field-inline-status success" role="status"> <span className="field-inline-status success" role="status">
{t('settings.modelsLoadedFromAccount', { {t('settings.modelsLoadedFromAccount', {
@ -5100,6 +5119,8 @@ function MediaProvidersSection({
setCfg, setCfg,
mediaProvidersNotice, mediaProvidersNotice,
onReloadMediaProviders, onReloadMediaProviders,
providerModelsCache: sharedProviderModelsCache,
onProviderModelsCacheChange,
pendingLocalProviderIds, pendingLocalProviderIds,
onChange, onChange,
}: { }: {
@ -5107,6 +5128,8 @@ function MediaProvidersSection({
setCfg: Dispatch<SetStateAction<AppConfig>>; setCfg: Dispatch<SetStateAction<AppConfig>>;
mediaProvidersNotice?: string | null; mediaProvidersNotice?: string | null;
onReloadMediaProviders?: () => Promise<AppConfig['mediaProviders'] | null>; onReloadMediaProviders?: () => Promise<AppConfig['mediaProviders'] | null>;
providerModelsCache?: Record<string, ProviderModelOption[]>;
onProviderModelsCacheChange?: Dispatch<SetStateAction<Record<string, ProviderModelOption[]>>>;
pendingLocalProviderIds: ReadonlySet<string>; pendingLocalProviderIds: ReadonlySet<string>;
onChange: (providerId: string) => void; onChange: (providerId: string) => void;
}) { }) {

View file

@ -1,12 +1,7 @@
import { createPortal } from 'react-dom';
import { forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState, type ButtonHTMLAttributes } from 'react';
import type { AgentModelOption } from '../types'; import type { AgentModelOption } from '../types';
// Render the `<option>` children for a model `<select>`. When the list
// contains `provider/model` ids (opencode's listing has hundreds), we
// group them under `<optgroup>` so the dropdown is navigable. Flat lists
// (Claude, Codex, Gemini, Qwen) are emitted as plain options.
//
// `'default'` is always pinned first (no group), so the user can return
// to "let the CLI decide" with one click.
export function renderModelOptions(models: AgentModelOption[]) { export function renderModelOptions(models: AgentModelOption[]) {
const groups = new Map<string, AgentModelOption[]>(); const groups = new Map<string, AgentModelOption[]>();
const flat: AgentModelOption[] = []; const flat: AgentModelOption[] = [];
@ -44,9 +39,6 @@ export function renderModelOptions(models: AgentModelOption[]) {
<optgroup key={provider} label={provider}> <optgroup key={provider} label={provider}>
{items.map((m) => ( {items.map((m) => (
<option key={m.id} value={m.id}> <option key={m.id} value={m.id}>
{/* Strip the redundant `provider/` prefix from the label
inside its own optgroup; keep it in the value so the
CLI sees the fully-qualified id. */}
{m.label.startsWith(`${provider}/`) {m.label.startsWith(`${provider}/`)
? m.label.slice(provider.length + 1) ? m.label.slice(provider.length + 1)
: m.label} : m.label}
@ -58,9 +50,202 @@ export function renderModelOptions(models: AgentModelOption[]) {
); );
} }
// True when the picked model id isn't one of the listed options — i.e. function matchesModelSearch(model: AgentModelOption, query: string): boolean {
// the user has typed a custom id and we should keep the custom input const haystack = `${model.id}\n${model.label}`.toLowerCase();
// visible / the dropdown showing "Custom…". return haystack.includes(query);
}
interface SearchableModelSelectProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onChange' | 'value'> {
models: AgentModelOption[];
value: string;
onChange: (value: string) => void;
searchPlaceholder: string;
searchInputTestId?: string;
popoverTestId?: string;
additionalOptions?: Array<{ value: string; label: string }>;
minSearchableOptions?: number;
}
export const SearchableModelSelect = forwardRef<
HTMLButtonElement,
SearchableModelSelectProps
>(function SearchableModelSelect(
{
models,
value,
onChange,
searchPlaceholder,
searchInputTestId,
popoverTestId,
additionalOptions,
minSearchableOptions = 8,
className,
...buttonProps
},
ref,
) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [popoverStyle, setPopoverStyle] = useState<{ top: number; left: number; width: number } | null>(null);
const wrapRef = useRef<HTMLDivElement | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const searchRef = useRef<HTMLInputElement | null>(null);
const listboxId = useMemo(
() => `model-picker-${Math.random().toString(36).slice(2, 10)}`,
[],
);
const allOptions = useMemo(() => {
const merged = new Map<string, AgentModelOption>();
for (const option of models) merged.set(option.id, option);
for (const option of additionalOptions ?? []) {
if (!merged.has(option.value)) {
merged.set(option.value, { id: option.value, label: option.label });
}
}
return Array.from(merged.values());
}, [additionalOptions, models]);
const selectedOption =
allOptions.find((option) => option.id === value) ??
(value ? { id: value, label: value } : allOptions[0] ?? null);
const normalizedQuery = query.trim().toLowerCase();
const filteredOptions = useMemo(() => {
if (!normalizedQuery) return allOptions;
return allOptions.filter(
(option) =>
option.id === value ||
option.id === CUSTOM_MODEL_SENTINEL ||
matchesModelSearch(option, normalizedQuery),
);
}, [allOptions, normalizedQuery, value]);
const shouldShowSearch = allOptions.length >= minSearchableOptions;
useEffect(() => {
if (!open) return;
const onPointerDown = (event: MouseEvent) => {
const target = event.target as Node;
if (wrapRef.current?.contains(target) || popoverRef.current?.contains(target)) return;
setOpen(false);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [open]);
useLayoutEffect(() => {
if (!open) return;
const updatePosition = () => {
const rect = wrapRef.current?.getBoundingClientRect();
if (!rect) return;
setPopoverStyle({
top: rect.bottom + 6,
left: rect.left,
width: rect.width,
});
};
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [open]);
useEffect(() => {
if (!open || !shouldShowSearch) return;
searchRef.current?.focus();
}, [open, shouldShowSearch]);
useEffect(() => {
if (!open) setQuery('');
}, [open]);
return (
<div className={`model-select-searchable${open ? ' is-open' : ''}`} ref={wrapRef}>
<button
{...buttonProps}
ref={ref}
type="button"
role="combobox"
aria-expanded={open}
aria-controls={listboxId}
aria-haspopup="listbox"
className={className}
onClick={(event) => {
buttonProps.onClick?.(event);
if (!event.defaultPrevented) setOpen((prev) => !prev);
}}
>
{selectedOption?.label ?? ''}
</button>
{open && popoverStyle
? createPortal(
<div
ref={popoverRef}
className="model-select-searchable__popover"
role="presentation"
data-testid={popoverTestId}
style={{
position: 'fixed',
top: `${popoverStyle.top}px`,
left: `${popoverStyle.left}px`,
width: `${popoverStyle.width}px`,
}}
>
{shouldShowSearch ? (
<div className="model-select-searchable__search-row">
<input
ref={searchRef}
type="search"
className="ds-picker-search model-select-searchable__input"
value={query}
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
data-testid={searchInputTestId}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
) : null}
<div className="model-select-searchable__list" id={listboxId} role="listbox">
{filteredOptions.map((option) => {
const active = option.id === value;
return (
<button
key={option.id}
type="button"
role="option"
aria-selected={active}
className={`model-select-searchable__option${active ? ' is-active' : ''}`}
data-selected={active ? 'true' : undefined}
onClick={() => {
onChange(option.id);
setOpen(false);
}}
>
<span className="model-select-searchable__option-label">{option.label}</span>
</button>
);
})}
{filteredOptions.length === 0 ? (
<div className="model-select-searchable__empty">No matching models</div>
) : null}
</div>
</div>,
document.body,
)
: null}
</div>
);
});
export function isCustomModel( export function isCustomModel(
modelId: string | null | undefined, modelId: string | null | undefined,
models: AgentModelOption[], models: AgentModelOption[],

View file

@ -1014,6 +1014,84 @@
} }
/* Shared select inside the popover */ /* Shared select inside the popover */
.model-select-searchable {
position: relative;
}
.model-select-searchable.is-open {
z-index: 1400;
}
.model-select-searchable__popover {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 1401;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.model-select-searchable__search-row {
padding: 8px;
border-bottom: 1px solid var(--border);
}
.model-select-searchable__input {
width: 100%;
}
.model-select-searchable__list {
max-height: 280px;
overflow-y: auto;
display: flex;
flex-direction: column;
padding: 6px;
}
.model-select-searchable__option {
appearance: none;
border: 0;
background: transparent;
text-align: left;
padding: 8px 30px 8px 10px;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 2px;
color: var(--text);
cursor: pointer;
position: relative;
}
.model-select-searchable__option:hover {
background: var(--bg-subtle);
}
.model-select-searchable__option.is-active {
background: var(--accent-tint);
color: var(--accent-strong);
}
.model-select-searchable__option[data-selected='true']::after {
content: '✓';
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
font-size: 12px;
font-weight: 700;
color: currentColor;
}
.model-select-searchable__option-label {
font-size: 12.5px;
line-height: 1.35;
}
.model-select-searchable__option-meta {
font-size: 11px;
color: var(--text-muted);
}
.model-select-searchable__empty {
padding: 12px 10px;
font-size: 12px;
color: var(--text-muted);
}
.inline-switcher__select { .inline-switcher__select {
appearance: none; appearance: none;
width: 100%; width: 100%;

View file

@ -1488,12 +1488,101 @@
} }
.agent-card-config .hint, .agent-card-config .hint,
.agent-model-row .hint { margin: 0; font-size: 11.5px; } .agent-model-row .hint { margin: 0; font-size: 11.5px; }
.model-select-searchable {
position: relative;
}
.model-select-searchable.is-open {
z-index: 1400;
}
.model-select-searchable__popover {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 1401;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.model-select-searchable__search-row {
padding: 8px;
border-bottom: 1px solid var(--border);
}
.model-select-searchable__input {
width: 100%;
}
.model-select-searchable__list {
max-height: 280px;
overflow-y: auto;
display: flex;
flex-direction: column;
padding: 6px;
}
.model-select-searchable__option {
appearance: none;
border: 0;
background: transparent;
text-align: left;
padding: 8px 30px 8px 10px;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 2px;
color: var(--text);
cursor: pointer;
position: relative;
}
.model-select-searchable__option:hover {
background: var(--bg-subtle);
}
.model-select-searchable__option.is-active {
background: var(--accent-tint);
color: var(--accent-strong);
}
.model-select-searchable__option[data-selected='true']::after {
content: '✓';
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
font-size: 12px;
font-weight: 700;
color: currentColor;
}
.model-select-searchable__option-label {
font-size: 12.5px;
line-height: 1.35;
}
.model-select-searchable__option-meta {
font-size: 11px;
color: var(--text-muted);
}
.model-select-searchable__empty {
padding: 12px 10px;
font-size: 12px;
color: var(--text-muted);
}
.agent-card-config .hint { .agent-card-config .hint {
color: var(--text-muted); color: var(--text-muted);
line-height: 1.35; line-height: 1.35;
} }
.agent-model-select-wrap { .agent-model-select-wrap {
position: relative; position: relative;
overflow: visible;
}
.settings-model-select {
width: 100%;
min-height: 36px;
text-align: left;
line-height: 1.35;
}
.settings-model-select--byok {
min-width: min(420px, 100%);
} }
.agent-model-select-wrap select { .agent-model-select-wrap select {
appearance: none; appearance: none;
@ -1502,6 +1591,12 @@
padding-right: 28px; padding-right: 28px;
width: 100%; width: 100%;
} }
.agent-model-select-wrap .model-select-searchable select {
background:
color-mix(in srgb, var(--bg-subtle) 82%, var(--bg-panel))
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2374716b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>")
no-repeat right 10px center;
}
.agent-card-config .agent-model-select-wrap select { .agent-card-config .agent-model-select-wrap select {
min-height: 36px; min-height: 36px;
border-color: color-mix(in srgb, var(--border) 78%, transparent); border-color: color-mix(in srgb, var(--border) 78%, transparent);

View file

@ -4,7 +4,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testi
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { InlineModelSwitcher } from '../../src/components/InlineModelSwitcher'; import { InlineModelSwitcher } from '../../src/components/InlineModelSwitcher';
import { AMR_LOGIN_TIMEOUT_MS } from '../../src/components/amrLoginPolling'; import { AMR_LOGIN_TIMEOUT_MS } from '../../src/components/amrLoginPolling';
import type { AgentInfo, AppConfig } from '../../src/types'; import type { AgentInfo, AppConfig, ProviderModelOption } from '../../src/types';
const baseConfig: AppConfig = { const baseConfig: AppConfig = {
mode: 'daemon', mode: 'daemon',
@ -48,6 +48,7 @@ const codexAgent: AgentInfo = {
function renderSwitcher( function renderSwitcher(
config: Partial<AppConfig> = {}, config: Partial<AppConfig> = {},
agents: AgentInfo[] = [amrAgent], agents: AgentInfo[] = [amrAgent],
providerModelsCache?: Record<string, ProviderModelOption[]>,
) { ) {
const onAgentModelChange = vi.fn(); const onAgentModelChange = vi.fn();
const view = render( const view = render(
@ -60,6 +61,7 @@ function renderSwitcher(
onAgentModelChange={onAgentModelChange} onAgentModelChange={onAgentModelChange}
onApiProtocolChange={vi.fn()} onApiProtocolChange={vi.fn()}
onApiModelChange={vi.fn()} onApiModelChange={vi.fn()}
providerModelsCache={providerModelsCache}
onOpenSettings={vi.fn()} onOpenSettings={vi.fn()}
/>, />,
); );
@ -167,13 +169,15 @@ describe('InlineModelSwitcher AMR row', () => {
expect(within(popover).queryByText(/Not signed in/i)).toBeNull(); expect(within(popover).queryByText(/Not signed in/i)).toBeNull();
expect(within(popover).queryByRole('button', { name: 'Sign in' })).toBeNull(); expect(within(popover).queryByRole('button', { name: 'Sign in' })).toBeNull();
const modelSelect = within(popover).getByTestId( const modelPicker = within(popover).getByTestId(
'inline-model-switcher-agent-model', 'inline-model-switcher-agent-model',
) as HTMLSelectElement; );
expect(Array.from(modelSelect.options).map((option) => option.value)).toEqual([ expect(modelPicker.textContent).toContain('Default');
'default', fireEvent.click(modelPicker);
'amr-cloud-latest', const modelPopover = screen.getByTestId('inline-model-switcher-agent-model-popover');
]); expect(
within(modelPopover).getAllByRole('option').map((option) => option.textContent?.trim()),
).toEqual(['Default', 'AMR Cloud Latest']);
}); });
it('persists the live AMR fallback when the saved AMR model is stale', async () => { it('persists the live AMR fallback when the saved AMR model is stale', async () => {
@ -196,14 +200,15 @@ describe('InlineModelSwitcher AMR row', () => {
fireEvent.click(screen.getByTestId('inline-model-switcher-chip')); fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
const popover = screen.getByTestId('inline-model-switcher-popover'); const popover = screen.getByTestId('inline-model-switcher-popover');
const modelSelect = within(popover).getByTestId( const modelPicker = within(popover).getByTestId(
'inline-model-switcher-agent-model', 'inline-model-switcher-agent-model',
) as HTMLSelectElement; );
expect(modelSelect.value).toBe('default'); expect(modelPicker.textContent).toContain('Default');
expect(Array.from(modelSelect.options).map((option) => option.value)).toEqual([ fireEvent.click(modelPicker);
'default', const modelPopover = screen.getByTestId('inline-model-switcher-agent-model-popover');
'amr-cloud-latest', expect(
]); within(modelPopover).getAllByRole('option').map((option) => option.textContent?.trim()),
).toEqual(['Default', 'AMR Cloud Latest']);
await waitFor(() => { await waitFor(() => {
expect(onAgentModelChange).toHaveBeenCalledWith('amr', { expect(onAgentModelChange).toHaveBeenCalledWith('amr', {
model: 'default', model: 'default',
@ -247,6 +252,78 @@ describe('InlineModelSwitcher AMR row', () => {
expect(within(popover).queryByRole('button', { name: 'Sign out' })).toBeNull(); expect(within(popover).queryByRole('button', { name: 'Sign out' })).toBeNull();
}); });
it('filters fetched BYOK provider models in the Home switcher search box', async () => {
renderSwitcher(
{
mode: 'api',
apiProtocol: 'openai',
baseUrl: 'https://api.openai.com/v1',
apiProviderBaseUrl: 'https://api.openai.com/v1',
apiKey: 'sk-test',
model: 'gpt-4.1-mini',
},
[amrAgent, codexAgent],
{
['openai\nhttps://api.openai.com/v1\nsk-test\n']: [
{ id: 'gpt-4.1-mini', label: 'gpt-4.1-mini' },
{ id: 'gpt-4.1', label: 'gpt-4.1' },
{ id: 'gpt-5.5', label: 'gpt-5.5' },
{ id: 'o4-mini', label: 'o4-mini' },
{ id: 'o3', label: 'o3' },
{ id: 'o1', label: 'o1' },
{ id: 'gpt-4o', label: 'gpt-4o' },
{ id: 'gpt-4o-mini', label: 'gpt-4o-mini' },
],
},
);
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
const modelPicker = screen.getByTestId('inline-model-switcher-api-model');
fireEvent.click(modelPicker);
const searchInput = screen.getByTestId(
'inline-model-switcher-api-model-search',
) as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: '5.5' } });
const modelPopover = screen.getByTestId('inline-model-switcher-api-model-popover');
expect(
within(modelPopover).getAllByRole('option').map((option) => option.textContent?.trim()),
).toEqual(['gpt-4.1-mini', 'gpt-5.5']);
});
it('prefers fetched BYOK provider models over only showing the currently selected custom model', async () => {
renderSwitcher(
{
mode: 'api',
apiProtocol: 'openai',
baseUrl: 'https://api.openai.com/v1',
apiProviderBaseUrl: 'https://api.openai.com/v1',
apiKey: 'sk-test',
model: 'gpt-4.1-mini',
},
[amrAgent, codexAgent],
{
['openai\nhttps://api.openai.com/v1\nsk-test\n']: [
{ id: 'gpt-4.1-mini', label: 'gpt-4.1-mini' },
{ id: 'gpt-4.1', label: 'gpt-4.1' },
{ id: 'gpt-5.5', label: 'gpt-5.5' },
],
},
);
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
const modelPicker = screen.getByTestId('inline-model-switcher-api-model');
fireEvent.click(modelPicker);
const modelPopover = screen.getByTestId('inline-model-switcher-api-model-popover');
expect(
within(modelPopover).getAllByRole('option').map((option) => option.textContent?.trim()),
).toEqual(expect.arrayContaining(['gpt-4.1-mini', 'gpt-4.1', 'gpt-5.5']));
expect(within(modelPopover).getAllByRole('option').length).toBeGreaterThan(1);
});
it('treats env-backed AMR login as signed in even when no user profile is available', async () => { it('treats env-backed AMR login as signed in even when no user profile is available', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString(); const url = input.toString();

View file

@ -450,7 +450,7 @@ describe('SettingsDialog execution settings BYOK interactions', () => {
target: { value: '1' }, target: { value: '1' },
}); });
expect((screen.getByLabelText('Model') as HTMLSelectElement).value).toBe('deepseek-chat'); expect(screen.getByRole('combobox', { name: 'Model' }).textContent).toContain('deepseek-chat');
expect((screen.getByLabelText('Base URL') as HTMLInputElement).value).toBe('https://api.deepseek.com'); expect((screen.getByLabelText('Base URL') as HTMLInputElement).value).toBe('https://api.deepseek.com');
}); });
@ -469,7 +469,7 @@ describe('SettingsDialog execution settings BYOK interactions', () => {
fireEvent.change(providerSelect, { target: { value: '1' } }); fireEvent.change(providerSelect, { target: { value: '1' } });
expect((screen.getByLabelText('Model') as HTMLSelectElement).value).toBe('deepseek-chat'); expect(screen.getByRole('combobox', { name: 'Model' }).textContent).toContain('deepseek-chat');
expect((screen.getByLabelText('Base URL') as HTMLInputElement).value).toBe( expect((screen.getByLabelText('Base URL') as HTMLInputElement).value).toBe(
'https://api.deepseek.com/anthropic', 'https://api.deepseek.com/anthropic',
); );
@ -771,10 +771,12 @@ describe('SettingsDialog execution settings BYOK interactions', () => {
}), }),
expect.any(AbortSignal), expect.any(AbortSignal),
); );
const select = screen.getByLabelText('Model') as HTMLSelectElement; const modelPicker = screen.getByRole('combobox', { name: 'Model' });
expect(Array.from(select.options).map((option) => option.value)).toEqual( fireEvent.click(modelPicker);
expect.arrayContaining(['gpt-account', 'gpt-4o', '__custom__']), const modelPopover = screen.getByTestId('settings-byok-model-popover');
); expect(
within(modelPopover).getAllByRole('option').map((option) => option.textContent?.trim()),
).toEqual(expect.arrayContaining(['Account Model (gpt-account)', 'gpt-4o', 'Custom (type below)…']));
fireEvent.click(screen.getByRole('tab', { name: 'Azure OpenAI' })); fireEvent.click(screen.getByRole('tab', { name: 'Azure OpenAI' }));
expect(screen.queryByRole('button', { name: 'Fetch models' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Fetch models' })).toBeNull();
@ -800,6 +802,45 @@ describe('SettingsDialog execution settings BYOK interactions', () => {
expect(screen.queryByText('Fill API key to test the connection.')).toBeNull(); expect(screen.queryByText('Fill API key to test the connection.')).toBeNull();
}); });
it('filters long BYOK model lists in Settings after provider discovery succeeds', async () => {
fetchProviderModelsMock.mockResolvedValueOnce({
ok: true,
kind: 'success',
latencyMs: 12,
models: [
{ id: 'gpt-4.1-mini', label: 'gpt-4.1-mini' },
{ id: 'gpt-4.1', label: 'gpt-4.1' },
{ id: 'gpt-5.5', label: 'gpt-5.5' },
{ id: 'o4-mini', label: 'o4-mini' },
{ id: 'o3', label: 'o3' },
{ id: 'o1', label: 'o1' },
{ id: 'gpt-4o', label: 'gpt-4o' },
{ id: 'gpt-4o-mini', label: 'gpt-4o-mini' },
],
});
renderSettingsDialog({
apiProtocol: 'openai',
apiKey: 'sk-openai',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4.1-mini',
apiProviderBaseUrl: 'https://api.openai.com/v1',
});
fireEvent.click(screen.getByRole('tab', { name: 'OpenAI' }));
expect(await screen.findByText('✓ Loaded 8 models from your account.')).toBeTruthy();
const modelPicker = screen.getByRole('combobox', { name: 'Model' });
fireEvent.click(modelPicker);
const searchInput = screen.getByTestId('settings-byok-model-search') as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: '5.5' } });
const modelPopover = screen.getByTestId('settings-byok-model-popover');
expect(
within(modelPopover).getAllByRole('option').map((option) => option.textContent?.trim()),
).toEqual(['gpt-4.1-mini', 'gpt-5.5', 'Custom (type below)…']);
});
it('fetches provider models, merges them into the picker, and preserves a custom current model', async () => { it('fetches provider models, merges them into the picker, and preserves a custom current model', async () => {
fetchProviderModelsMock.mockResolvedValueOnce({ fetchProviderModelsMock.mockResolvedValueOnce({
ok: true, ok: true,
@ -830,13 +871,12 @@ describe('SettingsDialog execution settings BYOK interactions', () => {
}), }),
expect.any(AbortSignal), expect.any(AbortSignal),
); );
const select = screen.getByLabelText('Model') as HTMLSelectElement; const modelPicker = screen.getByRole('combobox', { name: 'Model' });
expect(Array.from(select.options).map((option) => option.value)).toEqual( fireEvent.click(modelPicker);
expect.arrayContaining(['remote-alpha', 'gpt-4o', '__custom__']), const modelPopover = screen.getByTestId('settings-byok-model-popover');
);
expect( expect(
Array.from(select.options).some((option) => option.textContent === 'Remote Alpha (remote-alpha)'), within(modelPopover).getAllByRole('option').map((option) => option.textContent?.trim()),
).toBe(true); ).toEqual(expect.arrayContaining(['Remote Alpha (remote-alpha)', 'gpt-4o', 'Custom (type below)…']));
expect((screen.getByLabelText('Custom model id') as HTMLInputElement).value).toBe('custom-still-here'); expect((screen.getByLabelText('Custom model id') as HTMLInputElement).value).toBe('custom-still-here');
}); });
@ -916,9 +956,9 @@ describe('SettingsDialog execution settings BYOK interactions', () => {
fireEvent.change(screen.getByLabelText('API key'), { fireEvent.change(screen.getByLabelText('API key'), {
target: { value: 'sk-openai' }, target: { value: 'sk-openai' },
}); });
fireEvent.change(screen.getByLabelText('Model'), { fireEvent.click(screen.getByRole('combobox', { name: 'Model' }));
target: { value: '__custom__' }, const modelPopover = screen.getByTestId('settings-byok-model-popover');
}); fireEvent.click(within(modelPopover).getByRole('option', { name: 'Custom (type below)…' }));
const customModelInput = screen.getByLabelText('Custom model id') as HTMLInputElement; const customModelInput = screen.getByLabelText('Custom model id') as HTMLInputElement;
expect(customModelInput).toBeTruthy(); expect(customModelInput).toBeTruthy();
@ -1105,6 +1145,48 @@ describe('SettingsDialog execution settings Local CLI interactions', () => {
); );
}); });
it('filters long Local CLI model lists in Settings without hiding the current selection', () => {
renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex', agentModels: { codex: { model: 'gpt-4.1-mini' } } },
{
agents: [
{
...availableAgents[0]!,
modelsSource: 'live',
models: [
{ id: 'default', label: 'Default' },
{ id: 'gpt-4.1-mini', label: 'gpt-4.1-mini' },
{ id: 'gpt-4.1', label: 'gpt-4.1' },
{ id: 'gpt-5.5', label: 'gpt-5.5' },
{ id: 'o4-mini', label: 'o4-mini' },
{ id: 'o3', label: 'o3' },
{ id: 'o1', label: 'o1' },
{ id: 'gpt-4o', label: 'gpt-4o' },
{ id: 'gpt-4o-mini', label: 'gpt-4o-mini' },
],
},
],
},
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI/i }));
const codexCard = screen.getByRole('button', { name: /Codex CLI/i });
fireEvent.click(codexCard);
const modelPicker = screen.getByRole('combobox', {
name: en['settings.modelPicker'],
});
fireEvent.click(modelPicker);
const searchInput = screen.getByTestId('settings-agent-model-search-codex') as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: '5.5' } });
const modelPopover = screen.getByTestId('settings-agent-model-popover-codex');
expect(
within(modelPopover).getAllByRole('option').map((option) => option.textContent?.trim()),
).toEqual(['gpt-4.1-mini', 'gpt-5.5', 'Custom (type below)…']);
});
it('labels live CLI model metadata in the model picker', () => { it('labels live CLI model metadata in the model picker', () => {
renderSettingsDialog( renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex' }, { mode: 'daemon', agentId: 'codex' },
@ -1175,12 +1257,14 @@ describe('SettingsDialog execution settings Local CLI interactions', () => {
const modelPickers = screen.getAllByRole('combobox', { const modelPickers = screen.getAllByRole('combobox', {
name: en['settings.modelPicker'], name: en['settings.modelPicker'],
}) as HTMLSelectElement[]; });
expect(modelPickers).toHaveLength(1); expect(modelPickers).toHaveLength(1);
expect(modelPickers[0]?.value).toBe('glm-5'); expect(modelPickers[0]?.textContent).toContain('GLM 5');
fireEvent.click(modelPickers[0]!);
const modelPopover = screen.getByTestId('settings-agent-model-popover-amr');
expect( expect(
Array.from(modelPickers[0]?.options ?? []).map((option) => option.value), within(modelPopover).getAllByRole('option').map((option) => option.textContent?.trim()),
).toEqual(['glm-5', 'glm-5.1']); ).toEqual(['GLM 5', 'GLM 5.1']);
expect(screen.queryByLabelText(en['settings.modelCustomLabel'])).toBeNull(); expect(screen.queryByLabelText(en['settings.modelCustomLabel'])).toBeNull();
}); });