mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
parent
755d84e64c
commit
937946c6fa
10 changed files with 708 additions and 108 deletions
|
|
@ -96,6 +96,7 @@ import type {
|
|||
DesignSystemSummary,
|
||||
Project,
|
||||
ProjectTemplate,
|
||||
ProviderModelOption,
|
||||
PromptTemplateSummary,
|
||||
SkillSummary,
|
||||
} from './types';
|
||||
|
|
@ -232,6 +233,9 @@ function AppInner() {
|
|||
const [appVersionInfo, setAppVersionInfo] = useState<AppVersionInfo | null>(
|
||||
null,
|
||||
);
|
||||
const [providerModelsCache, setProviderModelsCache] = useState<
|
||||
Record<string, ProviderModelOption[]>
|
||||
>({});
|
||||
const [daemonMediaProviders, setDaemonMediaProviders] = useState<
|
||||
AppConfig['mediaProviders'] | null
|
||||
>(null);
|
||||
|
|
@ -1495,6 +1499,8 @@ function AppInner() {
|
|||
defaultDesignSystemId={config.designSystemId}
|
||||
agents={agents}
|
||||
config={config}
|
||||
providerModelsCache={providerModelsCache}
|
||||
onProviderModelsCacheChange={setProviderModelsCache}
|
||||
integrationInitialTab={integrationInitialTab}
|
||||
composioConfigLoading={composioConfigLoading}
|
||||
daemonLive={daemonLive}
|
||||
|
|
@ -1618,6 +1624,8 @@ function AppInner() {
|
|||
onReloadMediaProviders={reloadMediaProvidersFromDaemon}
|
||||
onSkillsChanged={handleSkillsChanged}
|
||||
onDesignSystemsChanged={handleDesignSystemsChanged}
|
||||
providerModelsCache={providerModelsCache}
|
||||
onProviderModelsCacheChange={setProviderModelsCache}
|
||||
/>
|
||||
) : null}
|
||||
<MemoryToast onOpenMemory={() => openSettings('memory')} />
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@ import {
|
|||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type ReactNode,
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
import {
|
||||
defaultScenarioPluginIdForProjectMetadata,
|
||||
|
|
@ -227,6 +229,8 @@ interface Props {
|
|||
// top-bar `InlineModelSwitcher` can render the active mode/agent/model
|
||||
// and persist changes through the same callbacks the project view uses.
|
||||
config: AppConfig;
|
||||
providerModelsCache?: Record<string, ProviderModelOption[]>;
|
||||
onProviderModelsCacheChange?: Dispatch<SetStateAction<Record<string, ProviderModelOption[]>>>;
|
||||
agents: AgentInfo[];
|
||||
daemonLive: boolean;
|
||||
onModeChange: (mode: ExecMode) => void;
|
||||
|
|
@ -353,6 +357,8 @@ export function EntryShell({
|
|||
designSystemsLoading = false,
|
||||
projectsLoading = false,
|
||||
config,
|
||||
providerModelsCache: sharedProviderModelsCache,
|
||||
onProviderModelsCacheChange,
|
||||
agents,
|
||||
daemonLive,
|
||||
onModeChange,
|
||||
|
|
@ -579,6 +585,7 @@ export function EntryShell({
|
|||
<span className="entry-discord-badge__label">Join Discord</span>
|
||||
</a>
|
||||
<InlineModelSwitcher
|
||||
providerModelsCache={sharedProviderModelsCache}
|
||||
config={config}
|
||||
agents={agents}
|
||||
daemonLive={daemonLive}
|
||||
|
|
@ -748,6 +755,8 @@ export function EntryShell({
|
|||
|
||||
function OnboardingView({
|
||||
config,
|
||||
providerModelsCache: sharedProviderModelsCache,
|
||||
onProviderModelsCacheChange,
|
||||
agents,
|
||||
daemonLive,
|
||||
onModeChange,
|
||||
|
|
@ -761,6 +770,8 @@ function OnboardingView({
|
|||
onFinish,
|
||||
}: {
|
||||
config: AppConfig;
|
||||
providerModelsCache?: Record<string, ProviderModelOption[]>;
|
||||
onProviderModelsCacheChange?: Dispatch<SetStateAction<Record<string, ProviderModelOption[]>>>;
|
||||
agents: AgentInfo[];
|
||||
daemonLive: boolean;
|
||||
onModeChange: (mode: ExecMode) => void;
|
||||
|
|
@ -808,9 +819,11 @@ function OnboardingView({
|
|||
| { status: 'running'; inputKey: string }
|
||||
| { status: 'done'; inputKey: string; result: ProviderModelsResponse }
|
||||
>({ status: 'idle' });
|
||||
const [providerModelsCache, setProviderModelsCache] = useState<
|
||||
const [localProviderModelsCache, setLocalProviderModelsCache] = useState<
|
||||
Record<string, ProviderModelOption[]>
|
||||
>({});
|
||||
const providerModelsCache = sharedProviderModelsCache ?? localProviderModelsCache;
|
||||
const setProviderModelsCache = onProviderModelsCacheChange ?? setLocalProviderModelsCache;
|
||||
const [profile, setProfile] = useState({
|
||||
role: '',
|
||||
orgSize: '',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
ConnectorDetail,
|
||||
|
|
@ -22,6 +22,7 @@ import type {
|
|||
ProjectMetadata,
|
||||
ProjectTemplate,
|
||||
PromptTemplateSummary,
|
||||
ProviderModelOption,
|
||||
SkillSummary,
|
||||
} from '../types';
|
||||
// `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
|
||||
// changes through the same channels as the project view.
|
||||
config: AppConfig;
|
||||
providerModelsCache?: Record<string, ProviderModelOption[]>;
|
||||
onProviderModelsCacheChange?: Dispatch<SetStateAction<Record<string, ProviderModelOption[]>>>;
|
||||
integrationInitialTab?: IntegrationTab;
|
||||
composioConfigLoading?: boolean;
|
||||
daemonLive: boolean;
|
||||
|
|
@ -255,6 +258,8 @@ export function EntryView({
|
|||
defaultDesignSystemId,
|
||||
agents,
|
||||
config,
|
||||
providerModelsCache,
|
||||
onProviderModelsCacheChange,
|
||||
integrationInitialTab,
|
||||
composioConfigLoading = false,
|
||||
daemonLive,
|
||||
|
|
@ -355,6 +360,8 @@ export function EntryView({
|
|||
designSystemsLoading={designSystemsLoading}
|
||||
projectsLoading={projectsLoading}
|
||||
config={config}
|
||||
providerModelsCache={providerModelsCache}
|
||||
onProviderModelsCacheChange={onProviderModelsCacheChange}
|
||||
agents={agents}
|
||||
daemonLive={daemonLive}
|
||||
onModeChange={onModeChange}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
startVelaLogin,
|
||||
type VelaLoginStatus,
|
||||
} 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 { AgentIcon } from './AgentIcon';
|
||||
import { Icon } from './Icon';
|
||||
|
|
@ -30,7 +30,7 @@ import {
|
|||
notifyAmrLoginStatusChanged,
|
||||
} from './amrLoginPolling';
|
||||
import { normalizeAgentModelChoice } from './agentModelSelection';
|
||||
import { renderModelOptions } from './modelOptions';
|
||||
import { SearchableModelSelect } from './modelOptions';
|
||||
|
||||
interface Props {
|
||||
config: AppConfig;
|
||||
|
|
@ -44,6 +44,7 @@ interface Props {
|
|||
) => void;
|
||||
onApiProtocolChange: (protocol: ApiProtocol) => void;
|
||||
onApiModelChange: (model: string) => void;
|
||||
providerModelsCache?: Record<string, ProviderModelOption[]>;
|
||||
onOpenSettings: (
|
||||
section?:
|
||||
| 'execution'
|
||||
|
|
@ -108,6 +109,7 @@ export function InlineModelSwitcher({
|
|||
onAgentModelChange,
|
||||
onApiProtocolChange,
|
||||
onApiModelChange,
|
||||
providerModelsCache,
|
||||
onOpenSettings,
|
||||
}: Props) {
|
||||
const t = useT();
|
||||
|
|
@ -348,6 +350,12 @@ export function InlineModelSwitcher({
|
|||
: null;
|
||||
|
||||
const apiProtocol = config.apiProtocol ?? 'anthropic';
|
||||
const providerModelsInputKey = [
|
||||
apiProtocol,
|
||||
config.baseUrl.trim().replace(/\/+$/, ''),
|
||||
config.apiKey.trim(),
|
||||
config.apiVersion?.trim() ?? '',
|
||||
].join('\n');
|
||||
const providerForProtocol = useMemo(
|
||||
() =>
|
||||
KNOWN_PROVIDERS.find(
|
||||
|
|
@ -359,7 +367,18 @@ export function InlineModelSwitcher({
|
|||
) ?? KNOWN_PROVIDERS.find((p) => p.protocol === apiProtocol),
|
||||
[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.
|
||||
// CLI: "Claude · Sonnet 4.5"; BYOK: "Anthropic · sonnet-4.5".
|
||||
|
|
@ -618,25 +637,33 @@ export function InlineModelSwitcher({
|
|||
<span className="inline-switcher__label">
|
||||
{t('inlineSwitcher.modelLabel')}
|
||||
</span>
|
||||
<select
|
||||
<SearchableModelSelect
|
||||
className="inline-switcher__select"
|
||||
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 ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange={(nextValue) =>
|
||||
onAgentModelChange?.(currentAgent.id, {
|
||||
model: e.target.value,
|
||||
model: nextValue,
|
||||
})
|
||||
}
|
||||
>
|
||||
{renderModelOptions(currentAgent.models)}
|
||||
{currentAgent.id !== 'amr' &&
|
||||
currentModelId &&
|
||||
!currentAgent.models.some((m) => m.id === currentModelId) ? (
|
||||
<option value={currentModelId}>
|
||||
{currentModelId} {t('inlineSwitcher.customSuffix')}
|
||||
</option>
|
||||
) : null}
|
||||
</select>
|
||||
additionalOptions={
|
||||
currentAgent.id !== 'amr' &&
|
||||
currentModelId &&
|
||||
!currentAgent.models.some((m) => m.id === currentModelId)
|
||||
? [
|
||||
{
|
||||
value: currentModelId,
|
||||
label: `${currentModelId} ${t('inlineSwitcher.customSuffix')}`,
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
|
|
@ -674,24 +701,27 @@ export function InlineModelSwitcher({
|
|||
{t('inlineSwitcher.modelLabel')}
|
||||
</span>
|
||||
{apiModelOptions.length > 0 ? (
|
||||
<select
|
||||
<SearchableModelSelect
|
||||
className="inline-switcher__select"
|
||||
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}
|
||||
onChange={(e) => onApiModelChange?.(e.target.value)}
|
||||
>
|
||||
{apiModelOptions.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{id}
|
||||
</option>
|
||||
))}
|
||||
{config.model &&
|
||||
!apiModelOptions.includes(config.model) ? (
|
||||
<option value={config.model}>
|
||||
{config.model} {t('inlineSwitcher.customSuffix')}
|
||||
</option>
|
||||
) : null}
|
||||
</select>
|
||||
onChange={(nextValue) => onApiModelChange?.(nextValue)}
|
||||
additionalOptions={
|
||||
config.model && !apiModelOptions.includes(config.model)
|
||||
? [
|
||||
{
|
||||
value: config.model,
|
||||
label: `${config.model} ${t('inlineSwitcher.customSuffix')}`,
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="inline-switcher__hint">
|
||||
{t('inlineSwitcher.openSettingsForModel')}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ import { ExportDiagnosticsRow } from './ExportDiagnosticsButton';
|
|||
import { Icon } from './Icon';
|
||||
import {
|
||||
CUSTOM_MODEL_SENTINEL,
|
||||
renderModelOptions,
|
||||
SearchableModelSelect,
|
||||
} from './modelOptions';
|
||||
import {
|
||||
DEFAULT_NOTIFICATIONS,
|
||||
|
|
@ -204,6 +204,8 @@ interface Props {
|
|||
onSkillsChanged?: (affectedSkillId?: string) => void;
|
||||
/** Same channel for design-system registry mutations. */
|
||||
onDesignSystemsChanged?: (affectedDesignSystemId?: string) => void;
|
||||
providerModelsCache?: Record<string, ProviderModelOption[]>;
|
||||
onProviderModelsCacheChange?: Dispatch<SetStateAction<Record<string, ProviderModelOption[]>>>;
|
||||
}
|
||||
|
||||
export interface AgentRefreshOptions {
|
||||
|
|
@ -835,6 +837,8 @@ export function SettingsDialog({
|
|||
onReloadMediaProviders,
|
||||
onSkillsChanged,
|
||||
onDesignSystemsChanged,
|
||||
providerModelsCache: sharedProviderModelsCache,
|
||||
onProviderModelsCacheChange,
|
||||
}: Props) {
|
||||
const { t, locale, setLocale } = useI18n();
|
||||
const analytics = useAnalytics();
|
||||
|
|
@ -945,9 +949,11 @@ export function SettingsDialog({
|
|||
initial.apiVersion ?? '',
|
||||
);
|
||||
});
|
||||
const [providerModelsCache, setProviderModelsCache] = useState<
|
||||
const [localProviderModelsCache, setLocalProviderModelsCache] = useState<
|
||||
Record<string, ProviderModelOption[]>
|
||||
>({});
|
||||
const providerModelsCache = sharedProviderModelsCache ?? localProviderModelsCache;
|
||||
const setProviderModelsCache = onProviderModelsCacheChange ?? setLocalProviderModelsCache;
|
||||
const agentTestAbortRef = useRef<AbortController | null>(null);
|
||||
const providerTestAbortRef = useRef<AbortController | null>(null);
|
||||
const providerModelsAbortRef = useRef<AbortController | null>(null);
|
||||
|
|
@ -959,7 +965,7 @@ export function SettingsDialog({
|
|||
const providerAutoTestKeyRef = useRef<string | null>(null);
|
||||
const apiKeyInputRef = 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 focusByokRequiredFieldAfterProtocolSwitchRef = useRef(false);
|
||||
const [apiModelCustomEditing, setApiModelCustomEditing] = useState(false);
|
||||
|
|
@ -2139,10 +2145,16 @@ export function SettingsDialog({
|
|||
</span>
|
||||
</span>
|
||||
<div className="agent-model-select-wrap">
|
||||
<select
|
||||
<SearchableModelSelect
|
||||
className="inline-switcher__select settings-model-select"
|
||||
value={selectValue}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === CUSTOM_MODEL_SENTINEL) {
|
||||
aria-label={t('settings.modelPicker')}
|
||||
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) => {
|
||||
const next = new Set(prev);
|
||||
next.add(selected.id);
|
||||
|
|
@ -2156,21 +2168,19 @@ export function SettingsDialog({
|
|||
next.delete(selected.id);
|
||||
return next;
|
||||
});
|
||||
setChoice({ model: e.target.value });
|
||||
setChoice({ model: nextValue });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{renderModelOptions(selected.models!)}
|
||||
{allowCustomModel ? (
|
||||
<option value={CUSTOM_MODEL_SENTINEL}>
|
||||
{t('settings.modelCustom')}
|
||||
</option>
|
||||
) : null}
|
||||
</select>
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
size={12}
|
||||
className="agent-model-select-chevron"
|
||||
additionalOptions={
|
||||
allowCustomModel
|
||||
? [
|
||||
{
|
||||
value: CUSTOM_MODEL_SENTINEL,
|
||||
label: t('settings.modelCustom'),
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
|
@ -3370,13 +3380,21 @@ export function SettingsDialog({
|
|||
*
|
||||
</span>
|
||||
</span>
|
||||
<select
|
||||
<SearchableModelSelect
|
||||
ref={modelSelectRef}
|
||||
className="inline-switcher__select settings-model-select settings-model-select--byok"
|
||||
aria-label={
|
||||
apiProtocol === 'azure'
|
||||
? t('settings.azureDeploymentModel')
|
||||
: 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}
|
||||
onFocus={() => {
|
||||
const byokProviderId = byokProtocolToTracking(apiProtocol);
|
||||
|
|
@ -3390,21 +3408,22 @@ export function SettingsDialog({
|
|||
});
|
||||
}
|
||||
}}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === CUSTOM_MODEL_SENTINEL) {
|
||||
onChange={(nextValue) => {
|
||||
if (nextValue === CUSTOM_MODEL_SENTINEL) {
|
||||
setApiModelCustomEditing(true);
|
||||
updateApiConfig({ model: '' });
|
||||
} else {
|
||||
setApiModelCustomEditing(false);
|
||||
updateApiConfig({ model: e.target.value });
|
||||
updateApiConfig({ model: nextValue });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{apiModelOptions.map((m) => (
|
||||
<option value={m.id} key={m.id}>{apiModelOptionLabel(m)}</option>
|
||||
))}
|
||||
<option value={CUSTOM_MODEL_SENTINEL}>{t('settings.modelCustom')}</option>
|
||||
</select>
|
||||
additionalOptions={[
|
||||
{
|
||||
value: CUSTOM_MODEL_SENTINEL,
|
||||
label: t('settings.modelCustom'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{loadedAccountModelCount > 0 ? (
|
||||
<span className="field-inline-status success" role="status">
|
||||
{t('settings.modelsLoadedFromAccount', {
|
||||
|
|
@ -5100,6 +5119,8 @@ function MediaProvidersSection({
|
|||
setCfg,
|
||||
mediaProvidersNotice,
|
||||
onReloadMediaProviders,
|
||||
providerModelsCache: sharedProviderModelsCache,
|
||||
onProviderModelsCacheChange,
|
||||
pendingLocalProviderIds,
|
||||
onChange,
|
||||
}: {
|
||||
|
|
@ -5107,6 +5128,8 @@ function MediaProvidersSection({
|
|||
setCfg: Dispatch<SetStateAction<AppConfig>>;
|
||||
mediaProvidersNotice?: string | null;
|
||||
onReloadMediaProviders?: () => Promise<AppConfig['mediaProviders'] | null>;
|
||||
providerModelsCache?: Record<string, ProviderModelOption[]>;
|
||||
onProviderModelsCacheChange?: Dispatch<SetStateAction<Record<string, ProviderModelOption[]>>>;
|
||||
pendingLocalProviderIds: ReadonlySet<string>;
|
||||
onChange: (providerId: string) => void;
|
||||
}) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
// 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[]) {
|
||||
const groups = new Map<string, AgentModelOption[]>();
|
||||
const flat: AgentModelOption[] = [];
|
||||
|
|
@ -44,9 +39,6 @@ export function renderModelOptions(models: AgentModelOption[]) {
|
|||
<optgroup key={provider} label={provider}>
|
||||
{items.map((m) => (
|
||||
<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.slice(provider.length + 1)
|
||||
: 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.
|
||||
// the user has typed a custom id and we should keep the custom input
|
||||
// visible / the dropdown showing "Custom…".
|
||||
function matchesModelSearch(model: AgentModelOption, query: string): boolean {
|
||||
const haystack = `${model.id}\n${model.label}`.toLowerCase();
|
||||
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(
|
||||
modelId: string | null | undefined,
|
||||
models: AgentModelOption[],
|
||||
|
|
|
|||
|
|
@ -1014,6 +1014,84 @@
|
|||
}
|
||||
|
||||
/* 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 {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
|
|
|
|||
|
|
@ -1488,12 +1488,101 @@
|
|||
}
|
||||
.agent-card-config .hint,
|
||||
.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 {
|
||||
color: var(--text-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.agent-model-select-wrap {
|
||||
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 {
|
||||
appearance: none;
|
||||
|
|
@ -1502,6 +1591,12 @@
|
|||
padding-right: 28px;
|
||||
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 {
|
||||
min-height: 36px;
|
||||
border-color: color-mix(in srgb, var(--border) 78%, transparent);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testi
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { InlineModelSwitcher } from '../../src/components/InlineModelSwitcher';
|
||||
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 = {
|
||||
mode: 'daemon',
|
||||
|
|
@ -48,6 +48,7 @@ const codexAgent: AgentInfo = {
|
|||
function renderSwitcher(
|
||||
config: Partial<AppConfig> = {},
|
||||
agents: AgentInfo[] = [amrAgent],
|
||||
providerModelsCache?: Record<string, ProviderModelOption[]>,
|
||||
) {
|
||||
const onAgentModelChange = vi.fn();
|
||||
const view = render(
|
||||
|
|
@ -60,6 +61,7 @@ function renderSwitcher(
|
|||
onAgentModelChange={onAgentModelChange}
|
||||
onApiProtocolChange={vi.fn()}
|
||||
onApiModelChange={vi.fn()}
|
||||
providerModelsCache={providerModelsCache}
|
||||
onOpenSettings={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
|
@ -167,13 +169,15 @@ describe('InlineModelSwitcher AMR row', () => {
|
|||
expect(within(popover).queryByText(/Not signed in/i)).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',
|
||||
) as HTMLSelectElement;
|
||||
expect(Array.from(modelSelect.options).map((option) => option.value)).toEqual([
|
||||
'default',
|
||||
'amr-cloud-latest',
|
||||
]);
|
||||
);
|
||||
expect(modelPicker.textContent).toContain('Default');
|
||||
fireEvent.click(modelPicker);
|
||||
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 () => {
|
||||
|
|
@ -196,14 +200,15 @@ describe('InlineModelSwitcher AMR row', () => {
|
|||
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
|
||||
|
||||
const popover = screen.getByTestId('inline-model-switcher-popover');
|
||||
const modelSelect = within(popover).getByTestId(
|
||||
const modelPicker = within(popover).getByTestId(
|
||||
'inline-model-switcher-agent-model',
|
||||
) as HTMLSelectElement;
|
||||
expect(modelSelect.value).toBe('default');
|
||||
expect(Array.from(modelSelect.options).map((option) => option.value)).toEqual([
|
||||
'default',
|
||||
'amr-cloud-latest',
|
||||
]);
|
||||
);
|
||||
expect(modelPicker.textContent).toContain('Default');
|
||||
fireEvent.click(modelPicker);
|
||||
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']);
|
||||
await waitFor(() => {
|
||||
expect(onAgentModelChange).toHaveBeenCalledWith('amr', {
|
||||
model: 'default',
|
||||
|
|
@ -247,6 +252,78 @@ describe('InlineModelSwitcher AMR row', () => {
|
|||
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 () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = input.toString();
|
||||
|
|
|
|||
|
|
@ -450,7 +450,7 @@ describe('SettingsDialog execution settings BYOK interactions', () => {
|
|||
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');
|
||||
});
|
||||
|
||||
|
|
@ -469,7 +469,7 @@ describe('SettingsDialog execution settings BYOK interactions', () => {
|
|||
|
||||
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(
|
||||
'https://api.deepseek.com/anthropic',
|
||||
);
|
||||
|
|
@ -771,10 +771,12 @@ describe('SettingsDialog execution settings BYOK interactions', () => {
|
|||
}),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
const select = screen.getByLabelText('Model') as HTMLSelectElement;
|
||||
expect(Array.from(select.options).map((option) => option.value)).toEqual(
|
||||
expect.arrayContaining(['gpt-account', 'gpt-4o', '__custom__']),
|
||||
);
|
||||
const modelPicker = screen.getByRole('combobox', { name: 'Model' });
|
||||
fireEvent.click(modelPicker);
|
||||
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' }));
|
||||
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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
fetchProviderModelsMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
|
@ -830,13 +871,12 @@ describe('SettingsDialog execution settings BYOK interactions', () => {
|
|||
}),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
const select = screen.getByLabelText('Model') as HTMLSelectElement;
|
||||
expect(Array.from(select.options).map((option) => option.value)).toEqual(
|
||||
expect.arrayContaining(['remote-alpha', 'gpt-4o', '__custom__']),
|
||||
);
|
||||
const modelPicker = screen.getByRole('combobox', { name: 'Model' });
|
||||
fireEvent.click(modelPicker);
|
||||
const modelPopover = screen.getByTestId('settings-byok-model-popover');
|
||||
expect(
|
||||
Array.from(select.options).some((option) => option.textContent === 'Remote Alpha (remote-alpha)'),
|
||||
).toBe(true);
|
||||
within(modelPopover).getAllByRole('option').map((option) => option.textContent?.trim()),
|
||||
).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');
|
||||
});
|
||||
|
||||
|
|
@ -916,9 +956,9 @@ describe('SettingsDialog execution settings BYOK interactions', () => {
|
|||
fireEvent.change(screen.getByLabelText('API key'), {
|
||||
target: { value: 'sk-openai' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('Model'), {
|
||||
target: { value: '__custom__' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('combobox', { name: 'Model' }));
|
||||
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;
|
||||
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', () => {
|
||||
renderSettingsDialog(
|
||||
{ mode: 'daemon', agentId: 'codex' },
|
||||
|
|
@ -1175,12 +1257,14 @@ describe('SettingsDialog execution settings Local CLI interactions', () => {
|
|||
|
||||
const modelPickers = screen.getAllByRole('combobox', {
|
||||
name: en['settings.modelPicker'],
|
||||
}) as HTMLSelectElement[];
|
||||
});
|
||||
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(
|
||||
Array.from(modelPickers[0]?.options ?? []).map((option) => option.value),
|
||||
).toEqual(['glm-5', 'glm-5.1']);
|
||||
within(modelPopover).getAllByRole('option').map((option) => option.textContent?.trim()),
|
||||
).toEqual(['GLM 5', 'GLM 5.1']);
|
||||
expect(screen.queryByLabelText(en['settings.modelCustomLabel'])).toBeNull();
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue