From 937946c6fa087bcf7550cfc14ef877d280385bc9 Mon Sep 17 00:00:00 2001 From: Amy <58060647+AmyShang-alt@users.noreply.github.com> Date: Fri, 29 May 2026 15:07:40 +0800 Subject: [PATCH] Improve model picker search and shared BYOK catalogs (#3262) (#3278) --- apps/web/src/App.tsx | 8 + apps/web/src/components/EntryShell.tsx | 15 +- apps/web/src/components/EntryView.tsx | 9 +- .../src/components/InlineModelSwitcher.tsx | 92 +++++--- apps/web/src/components/SettingsDialog.tsx | 81 ++++--- apps/web/src/components/modelOptions.tsx | 211 ++++++++++++++++-- apps/web/src/styles/home/entry-layout.css | 78 +++++++ apps/web/src/styles/workspace/artifacts.css | 95 ++++++++ .../components/InlineModelSwitcher.test.tsx | 105 +++++++-- .../SettingsDialog.execution.test.tsx | 122 ++++++++-- 10 files changed, 708 insertions(+), 108 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index ddad6689b..f3a7b6acb 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -96,6 +96,7 @@ import type { DesignSystemSummary, Project, ProjectTemplate, + ProviderModelOption, PromptTemplateSummary, SkillSummary, } from './types'; @@ -232,6 +233,9 @@ function AppInner() { const [appVersionInfo, setAppVersionInfo] = useState( null, ); + const [providerModelsCache, setProviderModelsCache] = useState< + Record + >({}); 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} openSettings('memory')} /> diff --git a/apps/web/src/components/EntryShell.tsx b/apps/web/src/components/EntryShell.tsx index 1874cb0dc..53c99f5eb 100644 --- a/apps/web/src/components/EntryShell.tsx +++ b/apps/web/src/components/EntryShell.tsx @@ -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; + onProviderModelsCacheChange?: Dispatch>>; 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({ Join Discord ; + onProviderModelsCacheChange?: Dispatch>>; 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 >({}); + const providerModelsCache = sharedProviderModelsCache ?? localProviderModelsCache; + const setProviderModelsCache = onProviderModelsCacheChange ?? setLocalProviderModelsCache; const [profile, setProfile] = useState({ role: '', orgSize: '', diff --git a/apps/web/src/components/EntryView.tsx b/apps/web/src/components/EntryView.tsx index 182e032a2..c92c53c92 100644 --- a/apps/web/src/components/EntryView.tsx +++ b/apps/web/src/components/EntryView.tsx @@ -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; + onProviderModelsCacheChange?: Dispatch>>; 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} diff --git a/apps/web/src/components/InlineModelSwitcher.tsx b/apps/web/src/components/InlineModelSwitcher.tsx index 2fb6afd9c..fb8dae29a 100644 --- a/apps/web/src/components/InlineModelSwitcher.tsx +++ b/apps/web/src/components/InlineModelSwitcher.tsx @@ -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; 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([...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({ {t('inlineSwitcher.modelLabel')} - + additionalOptions={ + currentAgent.id !== 'amr' && + currentModelId && + !currentAgent.models.some((m) => m.id === currentModelId) + ? [ + { + value: currentModelId, + label: `${currentModelId} ${t('inlineSwitcher.customSuffix')}`, + }, + ] + : undefined + } + /> ) : null} @@ -674,24 +701,27 @@ export function InlineModelSwitcher({ {t('inlineSwitcher.modelLabel')} {apiModelOptions.length > 0 ? ( - + onChange={(nextValue) => onApiModelChange?.(nextValue)} + additionalOptions={ + config.model && !apiModelOptions.includes(config.model) + ? [ + { + value: config.model, + label: `${config.model} ${t('inlineSwitcher.customSuffix')}`, + }, + ] + : undefined + } + /> ) : ( {t('inlineSwitcher.openSettingsForModel')} diff --git a/apps/web/src/components/SettingsDialog.tsx b/apps/web/src/components/SettingsDialog.tsx index 90f7cbb75..6fc58bde6 100644 --- a/apps/web/src/components/SettingsDialog.tsx +++ b/apps/web/src/components/SettingsDialog.tsx @@ -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; + onProviderModelsCacheChange?: Dispatch>>; } 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 >({}); + const providerModelsCache = sharedProviderModelsCache ?? localProviderModelsCache; + const setProviderModelsCache = onProviderModelsCacheChange ?? setLocalProviderModelsCache; const agentTestAbortRef = useRef(null); const providerTestAbortRef = useRef(null); const providerModelsAbortRef = useRef(null); @@ -959,7 +965,7 @@ export function SettingsDialog({ const providerAutoTestKeyRef = useRef(null); const apiKeyInputRef = useRef(null); const baseUrlInputRef = useRef(null); - const modelSelectRef = useRef(null); + const modelSelectRef = useRef(null); const customModelInputRef = useRef(null); const focusByokRequiredFieldAfterProtocolSwitchRef = useRef(false); const [apiModelCustomEditing, setApiModelCustomEditing] = useState(false); @@ -2139,10 +2145,16 @@ export function SettingsDialog({
- -
@@ -3370,13 +3380,21 @@ export function SettingsDialog({ * - + additionalOptions={[ + { + value: CUSTOM_MODEL_SENTINEL, + label: t('settings.modelCustom'), + }, + ]} + /> {loadedAccountModelCount > 0 ? ( {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>; mediaProvidersNotice?: string | null; onReloadMediaProviders?: () => Promise; + providerModelsCache?: Record; + onProviderModelsCacheChange?: Dispatch>>; pendingLocalProviderIds: ReadonlySet; onChange: (providerId: string) => void; }) { diff --git a/apps/web/src/components/modelOptions.tsx b/apps/web/src/components/modelOptions.tsx index b05057396..297a32345 100644 --- a/apps/web/src/components/modelOptions.tsx +++ b/apps/web/src/components/modelOptions.tsx @@ -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 `