mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
parent
755d84e64c
commit
937946c6fa
10 changed files with 708 additions and 108 deletions
|
|
@ -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')} />
|
||||||
|
|
|
||||||
|
|
@ -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: '',
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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')}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}) {
|
}) {
|
||||||
|
|
|
||||||
|
|
@ -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[],
|
||||||
|
|
|
||||||
|
|
@ -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%;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue