diff --git a/design-system/packages/ui/src/components/Combobox/Combobox.module.css b/design-system/packages/ui/src/components/Combobox/Combobox.module.css index 4dbf72e2ef..c3bd86b49e 100644 --- a/design-system/packages/ui/src/components/Combobox/Combobox.module.css +++ b/design-system/packages/ui/src/components/Combobox/Combobox.module.css @@ -253,6 +253,8 @@ } .popover { + position: fixed; + z-index: var(--bf-layer-popover); display: flex; min-inline-size: var(--bf-overlay-menu-inline-size); max-inline-size: calc(100vw - var(--bf-space-4)); @@ -266,18 +268,6 @@ box-shadow: var(--bf-shadow-menu); } - .popover[data-popover-mode="overlay"] { - position: fixed; - } - - .popover[data-popover-mode="inline"] { - position: absolute; - z-index: 1; - inset-block-start: calc(100% + var(--bf-space-1)); - inset-inline-start: 0; - inline-size: 100%; - } - .search { flex: 0 0 auto; padding: var(--bf-space-1); diff --git a/design-system/packages/ui/tests/combobox.test.mjs b/design-system/packages/ui/tests/combobox.test.mjs index 186e364848..42326cf49a 100644 --- a/design-system/packages/ui/tests/combobox.test.mjs +++ b/design-system/packages/ui/tests/combobox.test.mjs @@ -72,5 +72,8 @@ test("Combobox styling uses public field, overlay, action, and motion tokens", a assert.match(styles, /--bf-overlay-menu-surface-radius/); assert.match(styles, /--bf-color-action-neutral-surface/); assert.match(styles, /--bf-shadow-menu/); + assert.match(styles, /position:\s*fixed/); + assert.match(styles, /z-index:\s*var\(--bf-layer-popover\)/); + assert.doesNotMatch(styles, /data-popover-mode/); assert.doesNotMatch(styles, /#[0-9a-f]{3,8}/i); }); diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.appearance.ts b/src/web-ui/src/flow_chat/components/ModelSelector.appearance.ts index d9cf639a17..6217c44e8a 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.appearance.ts +++ b/src/web-ui/src/flow_chat/components/ModelSelector.appearance.ts @@ -8,8 +8,6 @@ export const modelSelectorAppearanceDescriptor: AppearanceSurfaceDescriptor = { { id: 'name' }, { id: 'reasoningSummary' }, { id: 'dropdown' }, - { id: 'level' }, - { id: 'back' }, { id: 'list' }, { id: 'option' }, { id: 'providerOption' }, diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.scss b/src/web-ui/src/flow_chat/components/ModelSelector.scss index 4aeb0c7ec7..a7c89ede4a 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.scss +++ b/src/web-ui/src/flow_chat/components/ModelSelector.scss @@ -216,29 +216,6 @@ } } - // One menu level. It is remounted on every step, so its entry animation is - // what makes the height change read as a step in a direction rather than as - // the surface resizing on its own. - &__level { - transition: - opacity 120ms ease, - transform 160ms cubic-bezier(0.23, 1, 0.32, 1); - - &[data-direction='forward'] { - @starting-style { - opacity: 0; - transform: translateX(8px); - } - } - - &[data-direction='back'] { - @starting-style { - opacity: 0; - transform: translateX(-8px); - } - } - } - &__settings-value { display: block; max-width: 112px; @@ -320,7 +297,7 @@ @media (prefers-reduced-motion: reduce) { .bitfun-model-selector__trigger, .bitfun-model-selector__chevron, - .bitfun-model-selector__level { + .bitfun-model-selector__submenu { transition-duration: 0ms; } diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.tsx b/src/web-ui/src/flow_chat/components/ModelSelector.tsx index f1cc8ba43e..4795d6c795 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelector.tsx @@ -12,7 +12,7 @@ import { Menu, MenuItem, MenuSection, MenuSeparator, OverflowText } from '@bitfu import React, { useState, useEffect, useId, useRef, useCallback, useLayoutEffect, useMemo, useSyncExternalStore } from 'react'; import { createPortal } from 'react-dom'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; -import { ChevronDown, ChevronLeft, ChevronRight, Check, RotateCcw, Zap } from 'lucide-react'; +import { ChevronDown, ChevronRight, Check, RotateCcw, Zap } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { configManager } from '@/infrastructure/config/services/ConfigManager'; import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; @@ -144,7 +144,6 @@ interface ProviderGroupInfo { } type NativeSubmenuKind = 'models' | 'reasoning'; -type ModelSelectorLevelDirection = 'none' | 'forward' | 'back'; const NATIVE_SUBMENU_GAP = 5; const NATIVE_SUBMENU_FALLBACK_WIDTH = 228; @@ -169,26 +168,6 @@ const ModelSelectorTooltipContent: React.FC<{ details: ModelSelectorTooltipDetai ); -const ModelSelectorMenuLevel: React.FC<{ - children: React.ReactNode; - direction: ModelSelectorLevelDirection; -}> = ({ children, direction }) => ( -
-
- {children} -
-
-); - // Helper: identify special model IDs. const isSpecialModel = (value: string): value is 'primary' | 'fast' => { return value === 'primary' || value === 'fast'; @@ -315,12 +294,10 @@ export const ModelSelector: React.FC = ({ const [acpOptions, setAcpOptions] = useState(null); const [dropdownOpen, setDropdownOpen] = useState(false); const [keyboardNavigationOpen, setKeyboardNavigationOpen] = useState(false); - /** Provider whose models the menu is currently showing; null is the provider level. */ + /** Provider whose models are open in the second-level flyout. */ const [activeProviderKey, setActiveProviderKey] = useState(null); - /** Click-open detail menu beside the stable native settings summary. */ + /** Click-open detail menu beside the provider-first model picker. */ const [nativeSubmenu, setNativeSubmenu] = useState(null); - /** Which way the provider level stepped inside the model submenu. */ - const [levelDirection, setLevelDirection] = useState('none'); const [loading, setLoading] = useState(false); const [reasoningLoading, setReasoningLoading] = useState(false); const acpRestoreToastShownRef = useRef(null); @@ -329,7 +306,7 @@ export const ModelSelector: React.FC = ({ const dropdownRef = useRef(null); const portalDropdownRef = useRef(null); const nativeSubmenuRef = useRef(null); - const nativeModelMenuItemRef = useRef(null); + const activeProviderMenuItemRef = useRef(null); const nativeReasoningMenuItemRef = useRef(null); const focusNativeSubmenuOnOpenRef = useRef(false); const triggerRef = useRef(null); @@ -585,14 +562,15 @@ export const ModelSelector: React.FC = ({ }; }, [dropdownOpen, dropdownPlacement]); - // Native model and reasoning choices are separate click-open flyouts. Keep - // them anchored to their summary row even though both menus are portalled. + // Provider models and reasoning choices are separate click-open flyouts. + // Keep each flyout anchored to its first-level row even though both menus + // are portalled. useLayoutEffect(() => { if (!dropdownOpen || !nativeSubmenu) return; const updatePosition = () => { const anchor = nativeSubmenu === 'models' - ? nativeModelMenuItemRef.current + ? activeProviderMenuItemRef.current : nativeReasoningMenuItemRef.current; const submenu = nativeSubmenuRef.current; if (!anchor || !submenu) return; @@ -641,7 +619,7 @@ export const ModelSelector: React.FC = ({ ? null : new ResizeObserver(updatePosition); const anchor = nativeSubmenu === 'models' - ? nativeModelMenuItemRef.current + ? activeProviderMenuItemRef.current : nativeReasoningMenuItemRef.current; if (anchor) resizeObserver?.observe(anchor); if (nativeSubmenuRef.current) resizeObserver?.observe(nativeSubmenuRef.current); @@ -902,53 +880,51 @@ export const ModelSelector: React.FC = ({ (preferredItem ?? firstItem)?.focus(); }, []); - const openNativeSubmenu = useCallback((kind: NativeSubmenuKind, moveFocus: boolean) => { + const openProviderSubmenu = useCallback((providerKey: string, moveFocus: boolean) => { focusNativeSubmenuOnOpenRef.current = moveFocus; - setActiveProviderKey(null); - setLevelDirection('none'); - if (nativeSubmenu === kind && moveFocus && !activeProviderKey) { + if (nativeSubmenu === 'models' && activeProviderKey === providerKey && moveFocus) { focusNativeSubmenuOnOpenRef.current = false; focusPreferredNativeSubmenuItem(); + return; } - setNativeSubmenu(kind); + setActiveProviderKey(providerKey); + setNativeSubmenu('models'); }, [activeProviderKey, focusPreferredNativeSubmenuItem, nativeSubmenu]); + const openReasoningSubmenu = useCallback((moveFocus: boolean) => { + focusNativeSubmenuOnOpenRef.current = moveFocus; + setActiveProviderKey(null); + if (nativeSubmenu === 'reasoning' && moveFocus) { + focusNativeSubmenuOnOpenRef.current = false; + focusPreferredNativeSubmenuItem(); + } + setNativeSubmenu('reasoning'); + }, [focusPreferredNativeSubmenuItem, nativeSubmenu]); + const closeNativeSubmenu = useCallback((restoreFocus: boolean) => { const anchor = nativeSubmenu === 'models' - ? nativeModelMenuItemRef.current + ? activeProviderMenuItemRef.current : nativeReasoningMenuItemRef.current; focusNativeSubmenuOnOpenRef.current = false; setActiveProviderKey(null); setNativeSubmenu(null); - setLevelDirection('none'); if (restoreFocus) anchor?.focus(); }, [nativeSubmenu]); - const toggleNativeSubmenu = useCallback((kind: NativeSubmenuKind) => { - if (nativeSubmenu === kind) { + const toggleReasoningSubmenu = useCallback(() => { + if (nativeSubmenu === 'reasoning') { closeNativeSubmenu(false); return; } - openNativeSubmenu(kind, false); - }, [closeNativeSubmenu, nativeSubmenu, openNativeSubmenu]); + openReasoningSubmenu(false); + }, [closeNativeSubmenu, nativeSubmenu, openReasoningSubmenu]); - const openProviderLevel = useCallback((providerKey: string) => { - setActiveProviderKey(providerKey); - setLevelDirection('forward'); - }, []); - - const closeProviderLevel = useCallback(() => { - setActiveProviderKey(null); - setLevelDirection('back'); - }, []); - - // Reopening starts with only the stable summary. A provider removed while - // its submenu is open must not leave the model flyout on a missing level. + // Reopening starts at the provider list. A provider removed while its model + // flyout is open must not leave an orphaned second-level menu behind. useEffect(() => { if (!dropdownOpen) { setActiveProviderKey(null); setNativeSubmenu(null); - setLevelDirection('none'); return; } if (nativeSubmenu !== 'models' && activeProviderKey) { @@ -956,13 +932,13 @@ export const ModelSelector: React.FC = ({ return; } if (activeProviderKey && !activeProviderGroup) { - closeProviderLevel(); + closeNativeSubmenu(false); } - }, [activeProviderGroup, activeProviderKey, closeProviderLevel, dropdownOpen, nativeSubmenu]); + }, [activeProviderGroup, activeProviderKey, closeNativeSubmenu, dropdownOpen, nativeSubmenu]); const currentNativeModelId = getCurrentModelId(); const concreteModelId = resolveConcreteModelId(currentNativeModelId, defaultModels); - /** Provider that owns the pinned model, so the provider level can mark it. */ + /** Provider that owns the pinned model, so the first level can mark it. */ const selectedProviderKey = useMemo((): string | null => { if (isSpecialModel(currentNativeModelId)) return null; return providerGroups.find( @@ -1360,15 +1336,14 @@ export const ModelSelector: React.FC = ({ } }, [dropdownOpen, isAcpSession, loadAcpOptions]); - const handleNativeSubmenuTriggerKeyDown = useCallback(( - kind: NativeSubmenuKind, + const handleReasoningSubmenuTriggerKeyDown = useCallback(( event: React.KeyboardEvent, ) => { if (event.key !== 'ArrowRight') return; event.preventDefault(); event.stopPropagation(); - openNativeSubmenu(kind, true); - }, [openNativeSubmenu]); + openReasoningSubmenu(true); + }, [openReasoningSubmenu]); const handleDropdownKeyDown = useCallback((event: React.KeyboardEvent) => { if (event.defaultPrevented) return; @@ -1387,15 +1362,16 @@ export const ModelSelector: React.FC = ({ if (event.key === 'ArrowRight') { const focusedElement = document.activeElement as HTMLElement | null; const focusedTarget = focusedElement?.dataset?.modelMenuTarget; + const focusedProviderKey = focusedElement?.dataset?.providerKey; if (!externalSelection && !isAcpSession) { - if (focusedTarget === 'models') { + if (focusedProviderKey) { event.preventDefault(); - openNativeSubmenu('models', true); + openProviderSubmenu(focusedProviderKey, true); return; } if (focusedTarget === 'reasoning') { event.preventDefault(); - openNativeSubmenu('reasoning', true); + openReasoningSubmenu(true); return; } } @@ -1406,7 +1382,8 @@ export const ModelSelector: React.FC = ({ externalSelection, isAcpSession, nativeSubmenu, - openNativeSubmenu, + openProviderSubmenu, + openReasoningSubmenu, ]); const handleNativeSubmenuKeyDown = useCallback((event: React.KeyboardEvent) => { @@ -1415,29 +1392,10 @@ export const ModelSelector: React.FC = ({ if (event.key === 'Escape' || event.key === 'ArrowLeft') { event.preventDefault(); event.stopPropagation(); - if (activeProviderKey) { - closeProviderLevel(); - } else { - closeNativeSubmenu(true); - } - return; - } - - if (event.key === 'ArrowRight' && nativeSubmenu === 'models' && !activeProviderKey) { - const focusedElement = document.activeElement as HTMLElement | null; - const focusedProviderKey = focusedElement?.dataset?.providerKey; - if (focusedProviderKey) { - event.preventDefault(); - event.stopPropagation(); - openProviderLevel(focusedProviderKey); - } + closeNativeSubmenu(true); } }, [ - activeProviderKey, closeNativeSubmenu, - closeProviderLevel, - nativeSubmenu, - openProviderLevel, ]); useEffect(() => { @@ -1457,46 +1415,14 @@ export const ModelSelector: React.FC = ({ return () => window.cancelAnimationFrame(frameId); }, [dropdownOpen, keyboardNavigationOpen]); - // Keyboard-opened flyouts receive focus. Provider steps also move focus - // because the clicked row is replaced inside the model flyout. - const previousNativeProviderKeyRef = useRef(activeProviderKey); + // Keyboard-opened flyouts receive focus; pointer-opened flyouts leave focus + // on the provider or reasoning row so the stable first level stays usable. useLayoutEffect(() => { - const previousProviderKey = previousNativeProviderKeyRef.current; - previousNativeProviderKeyRef.current = activeProviderKey; if (!dropdownOpen || !nativeSubmenu) return; - const providerLevelChanged = nativeSubmenu === 'models' - && previousProviderKey !== activeProviderKey; - if (!focusNativeSubmenuOnOpenRef.current && !providerLevelChanged) return; + if (!focusNativeSubmenuOnOpenRef.current) return; focusNativeSubmenuOnOpenRef.current = false; - - const menu = nativeSubmenuRef.current; - if (!menu) return; - - if (activeProviderKey) { - const selectedModel = menu.querySelector( - 'button[role="menuitemradio"][aria-checked="true"]', - ); - const firstModel = menu.querySelector( - 'button[role="menuitemradio"]:not(:disabled)', - ); - (selectedModel ?? firstModel)?.focus(); - return; - } - - if (nativeSubmenu === 'models' && previousProviderKey) { - const providerRows = Array.from( - menu.querySelectorAll('button[data-provider-key]'), - ); - const targetRow = providerRows.find( - row => row.dataset.providerKey === previousProviderKey, - ); - (targetRow ?? providerRows[0])?.focus(); - return; - } - focusPreferredNativeSubmenuItem(); }, [ - activeProviderKey, dropdownOpen, focusPreferredNativeSubmenuItem, nativeSubmenu, @@ -1888,7 +1814,7 @@ export const ModelSelector: React.FC = ({ data-keyboard-open={keyboardNavigationOpen ? 'true' : 'false'} data-placement={resolvedDropdownPlacement} data-open={dropdownOpen ? 'true' : 'false'} - data-menu-level="settings" + data-menu-level="providers" aria-hidden={!dropdownOpen} {...(!dropdownOpen ? { inert: '' } : {})} aria-label={t('modelSelector.modelSettings')} @@ -1896,48 +1822,152 @@ export const ModelSelector: React.FC = ({ > - - {getModelDisplayLabel(currentModel, t('modelSelector.primaryModel'))} - - )} - onClick={() => toggleNativeSubmenu('models')} - onKeyDown={(event) => handleNativeSubmenuTriggerKeyDown('models', event)} - shortcut={} - > - {t('modelSelector.model')} - + {providerGroups.map(group => { + const isSelected = selectedProviderKey === group.key; + const isOpen = nativeSubmenu === 'models' && activeProviderKey === group.key; + const selectedModel = isSelected + ? group.models.find(model => model.id === currentModelId) ?? null + : null; + + return ( + + } + onClick={() => { + if (isOpen) closeNativeSubmenu(false); + else openProviderSubmenu(group.key, false); + }} + > +
+ + {group.providerName} + + {selectedModel && ( + + + {selectedModel.modelName} + + + )} +
+
+
+ ); + })} + + + + {(() => { + const primaryModel = allModels.find(m => m.id === defaultModels.primary); + const primaryTooltip = primaryModel + ? buildResolvedModelTooltipText(primaryModel.model_name, { + providerName: getProviderDisplayName(primaryModel), + contextWindow: primaryModel.context_window + }, t('modelSelector.primaryModelDesc')) + : t('modelSelector.primaryModelDesc'); + return ( + + : null} + onClick={() => handleSelectModel('primary')} + > + {t('modelSelector.primaryModel')} + + + ); + })()} + + {(() => { + const fastModel = allModels.find(m => m.id === defaultModels.fast); + const fastTooltip = fastModel + ? buildResolvedModelTooltipText(fastModel.model_name, { + providerName: getProviderDisplayName(fastModel), + contextWindow: fastModel.context_window + }, t('modelSelector.fastModelDesc')) + : t('modelSelector.fastModelDesc'); + return ( + + : null} + onClick={() => handleSelectModel('fast')} + > + {t('modelSelector.fastModel')} + + + ); + })()} {hasNativeReasoningSettings && ( - - {currentReasoningLabel} - - )} - onClick={() => toggleNativeSubmenu('reasoning')} - onKeyDown={(event) => handleNativeSubmenuTriggerKeyDown('reasoning', event)} - shortcut={} - > - {t('reasoningSelector.title')} - + <> + + + {currentReasoningLabel} + + )} + onClick={toggleReasoningSubmenu} + onKeyDown={handleReasoningSubmenuTriggerKeyDown} + shortcut={} + > + {t('reasoningSelector.title')} + + )} @@ -1965,7 +1995,7 @@ export const ModelSelector: React.FC = ({ style={nativeSubmenuStyle} data-testid="chat-model-selector-submenu" data-submenu-kind={nativeSubmenu} - data-menu-level={activeProviderGroup ? 'provider' : nativeSubmenu} + data-menu-level={nativeSubmenu} data-placement={nativeSubmenuPlacement} data-bf-component="model-selector" data-bf-part="dropdown" @@ -1976,9 +2006,11 @@ export const ModelSelector: React.FC = ({ : t('modelSelector.modelSelection')} onKeyDown={handleNativeSubmenuKeyDown} > - {nativeSubmenu === 'reasoning' ? ( <> @@ -2018,158 +2050,31 @@ export const ModelSelector: React.FC = ({ })} ) : activeProviderGroup ? ( - <> - } - onClick={closeProviderLevel} - > - {activeProviderGroup.providerName} - - - {activeProviderGroup.models.map(model => { - const isSelected = currentModelId === model.id; - - return ( - - : null} - onClick={() => handleSelectModel(model.id)} - > - {model.modelName} - - - ); - })} - - ) : ( - <> - {(() => { - const primaryModel = allModels.find(m => m.id === defaultModels.primary); - const primaryTooltip = primaryModel - ? buildResolvedModelTooltipText(primaryModel.model_name, { - providerName: getProviderDisplayName(primaryModel), - contextWindow: primaryModel.context_window - }, t('modelSelector.primaryModelDesc')) - : t('modelSelector.primaryModelDesc'); - return ( - - : null} - onClick={() => handleSelectModel('primary')} - > - {t('modelSelector.primaryModel')} - - - ); - })()} - - {(() => { - const fastModel = allModels.find(m => m.id === defaultModels.fast); - const fastTooltip = fastModel - ? buildResolvedModelTooltipText(fastModel.model_name, { - providerName: getProviderDisplayName(fastModel), - contextWindow: fastModel.context_window - }, t('modelSelector.fastModelDesc')) - : t('modelSelector.fastModelDesc'); - return ( - - : null} - onClick={() => handleSelectModel('fast')} - > - {t('modelSelector.fastModel')} - - - ); - })()} - - + activeProviderGroup.models.map(model => { + const isSelected = currentModelId === model.id; - {providerGroups.map(group => { - const isSelected = selectedProviderKey === group.key; - const selectedModel = isSelected - ? group.models.find(model => model.id === currentModelId) ?? null - : null; - - return ( - + : null} + onClick={() => handleSelectModel(model.id)} > - } - onClick={() => openProviderLevel(group.key)} - > -
- - {group.providerName} - - {selectedModel && ( - - - {selectedModel.modelName} - - - )} -
-
-
- ); - })} - - )} -
+ {model.modelName} + + + ); + }) + ) : null} +
, getAppearanceOverlayHost() )} diff --git a/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx b/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx index 99c6d2d1bd..e2f0fd49fd 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx @@ -291,11 +291,6 @@ describe('ModelSelector external transport reuse', () => { await act(async () => { container.querySelector('[data-testid="chat-model-selector-btn"]')?.click(); }); - await act(async () => { - document.body.querySelector( - '[data-testid="chat-model-selector-settings-model"]', - )?.click(); - }); await act(async () => { document.body.querySelector( '[data-testid="chat-model-selector-provider"][data-provider-key="provider-shared"]', diff --git a/src/web-ui/src/flow_chat/components/ModelSelectorProviderLevels.test.tsx b/src/web-ui/src/flow_chat/components/ModelSelectorProviderLevels.test.tsx index 1db9ff553c..cb1a31626d 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelectorProviderLevels.test.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelectorProviderLevels.test.tsx @@ -130,11 +130,6 @@ describe('ModelSelector provider levels', () => { const openMenu = async () => { await openSettingsMenu(); - await act(async () => { - document.body.querySelector( - '[data-testid="chat-model-selector-settings-model"]', - )?.click(); - }); }; const openProvider = async (providerKey: string) => { @@ -153,6 +148,14 @@ describe('ModelSelector provider levels', () => { '[data-bf-part="section-items"]', ) ?? null; + const settingsSection = () => document.body.querySelector( + '[data-testid="chat-model-selector-settings"]', + ); + + const sharedSettingsItems = () => settingsSection()?.querySelector( + '[data-bf-part="section-items"]', + ) ?? null; + const renderSelector = async ( models: unknown[] = CATALOG_MODELS, modeModel = 'primary', @@ -211,7 +214,7 @@ describe('ModelSelector provider levels', () => { vi.clearAllMocks(); }); - it('opens with model and reasoning settings, omits speed, and can restore defaults', async () => { + it('opens with providers, reasoning settings, and a way to restore defaults', async () => { flowChatStoreMocks.sessions.set('session-a', { config: { agentType: 'agentic', @@ -238,18 +241,18 @@ describe('ModelSelector provider levels', () => { await renderSelector(CATALOG_MODELS, 'primary', 'session-a'); await openSettingsMenu(); - const settings = document.body.querySelector( - '[data-testid="chat-model-selector-settings"]', - ); + const settings = settingsSection(); expect(settings).not.toBeNull(); expect(settings?.querySelector( '[data-testid="chat-model-selector-settings-model"]', - )?.textContent).toContain('umbra-main-native'); + )).toBeNull(); + expect(providerRows().map(row => row.dataset.providerKey)) + .toEqual(['provider-acme', 'provider-umbra']); + expect(providerRows().find(row => row.dataset.providerKey === 'provider-umbra')?.textContent) + .toContain('umbra-main-native'); expect(settings?.querySelector( '[data-testid="chat-model-selector-settings-reasoning"]', )?.textContent).toContain('reasoningSelector.levels.high'); - expect(settings?.querySelectorAll('button[role="menuitem"]')).toHaveLength(3); - expect(settings?.textContent).not.toContain('modelSelector.fastMode'); const trigger = container.querySelector( '[data-testid="chat-model-selector-btn"]', ); @@ -331,18 +334,19 @@ describe('ModelSelector provider levels', () => { await renderSelector(); await openMenu(); - expect(document.body.querySelector( - '[data-testid="chat-model-selector-settings"]', - )).not.toBeNull(); - expect(nativeSubmenu()?.dataset.submenuKind).toBe('models'); - expect(sharedSubmenuItems()).not.toBeNull(); - expect(providerRows().every(row => sharedSubmenuItems()?.contains(row))).toBe(true); - expect(sharedSubmenuItems()?.contains(modelOption('primary'))).toBe(true); - expect(sharedSubmenuItems()?.contains(modelOption('fast'))).toBe(true); + expect(settingsSection()).not.toBeNull(); + expect(nativeSubmenu()).toBeNull(); + expect(sharedSettingsItems()).not.toBeNull(); + expect(providerRows().every(row => sharedSettingsItems()?.contains(row))).toBe(true); + expect(sharedSettingsItems()?.contains(modelOption('primary'))).toBe(true); + expect(sharedSettingsItems()?.contains(modelOption('fast'))).toBe(true); expect(providerRows().map(row => row.dataset.providerKey)) .toEqual(['provider-acme', 'provider-umbra']); expect(modelOption('primary')).not.toBeNull(); expect(modelOption('fast')).not.toBeNull(); + expect(settingsSection()?.querySelector( + '[data-testid="chat-model-selector-settings-model"]', + )).toBeNull(); expect(document.body.querySelector( '[data-testid="chat-model-selector-provider-selected-model"]', )).toBeNull(); @@ -356,16 +360,17 @@ describe('ModelSelector provider levels', () => { await openMenu(); await openProvider('provider-acme'); - expect(document.body.querySelector('[data-testid="chat-model-selector-back"]')).not.toBeNull(); - expect(providerRows()).toHaveLength(0); + expect(nativeSubmenu()?.dataset.submenuKind).toBe('models'); + expect(providerRows()).toHaveLength(2); expect(modelOption('acme-fast')).not.toBeNull(); expect(modelOption('acme-deep')).not.toBeNull(); expect(modelOption('umbra-main')).toBeNull(); expect(sharedSubmenuItems()).not.toBeNull(); expect(sharedSubmenuItems()?.contains(modelOption('acme-fast'))).toBe(true); expect(sharedSubmenuItems()?.contains(modelOption('acme-deep'))).toBe(true); - // The symbolic selectors belong to the provider level and are not repeated. - expect(modelOption('primary')).toBeNull(); + // The symbolic selectors stay on the first level and are not repeated. + expect(sharedSettingsItems()?.contains(modelOption('primary'))).toBe(true); + expect(sharedSubmenuItems()?.contains(modelOption('primary'))).toBe(false); await act(async () => { modelOption('acme-deep')?.click(); @@ -404,23 +409,19 @@ describe('ModelSelector provider levels', () => { ).toBeNull(); }); - it('returns to the provider level from the back control and on reopen', async () => { + it('keeps the provider level stable while switching providers and on reopen', async () => { await renderSelector(); await openMenu(); await openProvider('provider-acme'); - await act(async () => { - document.body.querySelector( - '[data-testid="chat-model-selector-back"]', - )?.click(); - }); expect(providerRows()).toHaveLength(2); - expect(modelOption('acme-deep')).toBeNull(); + expect(sharedSubmenuItems()?.contains(modelOption('acme-deep'))).toBe(true); - await openProvider('provider-acme'); - expect(modelOption('acme-deep')).not.toBeNull(); + await openProvider('provider-umbra'); + expect(sharedSubmenuItems()?.contains(modelOption('umbra-main'))).toBe(true); + expect(modelOption('acme-deep')).toBeNull(); - // Closing and reopening starts over at the settings summary. + // Closing and reopening starts over at the provider list. await act(async () => { container.querySelector( '[data-testid="chat-model-selector-btn"]', @@ -430,11 +431,12 @@ describe('ModelSelector provider levels', () => { expect(document.body.querySelector( '[data-testid="chat-model-selector-settings"]', )).not.toBeNull(); - expect(providerRows()).toHaveLength(0); + expect(providerRows()).toHaveLength(2); + expect(nativeSubmenu()).toBeNull(); expect(modelOption('acme-deep')).toBeNull(); }); - it('lets Escape step out of a provider, then the submenu, before closing the menu', async () => { + it('lets Escape close the model flyout before closing the provider menu', async () => { await renderSelector(); await openMenu(); await openProvider('provider-acme'); @@ -450,14 +452,8 @@ describe('ModelSelector provider levels', () => { await pressEscape(); const menu = document.body.querySelector('[data-testid="chat-model-selector-menu"]'); expect(menu?.getAttribute('data-open')).toBe('true'); - expect(providerRows()).toHaveLength(2); - - await pressEscape(); expect(nativeSubmenu()).toBeNull(); - expect(document.body.querySelector( - '[data-testid="chat-model-selector-settings"]', - )).not.toBeNull(); - expect(menu?.getAttribute('data-open')).toBe('true'); + expect(providerRows()).toHaveLength(2); await pressEscape(); expect( @@ -489,34 +485,34 @@ describe('ModelSelector provider levels', () => { await renderSelector(CATALOG_MODELS, 'primary', 'session-a'); await openSettingsMenu(); - const modelRow = document.body.querySelector( - '[data-testid="chat-model-selector-settings-model"]', + const providerRow = document.body.querySelector( + '[data-testid="chat-model-selector-provider"][data-provider-key="provider-acme"]', ); const reasoningRow = document.body.querySelector( '[data-testid="chat-model-selector-settings-reasoning"]', ); await act(async () => { - modelRow?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); - modelRow?.focus(); + providerRow?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + providerRow?.focus(); }); expect(nativeSubmenu()).toBeNull(); - await act(async () => modelRow?.click()); - expect(modelRow?.getAttribute('aria-expanded')).toBe('true'); + await act(async () => providerRow?.click()); + expect(providerRow?.getAttribute('aria-expanded')).toBe('true'); expect(nativeSubmenu()?.dataset.submenuKind).toBe('models'); expect(document.body.querySelector( '[data-testid="chat-model-selector-settings"]', )).not.toBeNull(); await act(async () => { - modelRow?.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true })); + providerRow?.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true })); reasoningRow?.focus(); }); expect(nativeSubmenu()?.dataset.submenuKind).toBe('models'); await act(async () => reasoningRow?.click()); - expect(modelRow?.getAttribute('aria-expanded')).toBe('false'); + expect(providerRow?.getAttribute('aria-expanded')).toBe('false'); expect(reasoningRow?.getAttribute('aria-expanded')).toBe('true'); expect(nativeSubmenu()?.dataset.submenuKind).toBe('reasoning'); @@ -530,13 +526,13 @@ describe('ModelSelector provider levels', () => { it('supports Right and Left Arrow navigation and closes both menus on outside click', async () => { await renderSelector(); await openSettingsMenu(); - const modelRow = document.body.querySelector( - '[data-testid="chat-model-selector-settings-model"]', + const providerRow = document.body.querySelector( + '[data-testid="chat-model-selector-provider"][data-provider-key="provider-acme"]', ); - modelRow?.focus(); + providerRow?.focus(); await act(async () => { - modelRow?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + providerRow?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); await new Promise(resolve => window.setTimeout(resolve, 25)); }); expect(nativeSubmenu()?.dataset.submenuKind).toBe('models'); @@ -546,9 +542,9 @@ describe('ModelSelector provider levels', () => { nativeSubmenu()?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); }); expect(nativeSubmenu()).toBeNull(); - expect(document.activeElement).toBe(modelRow); + expect(document.activeElement).toBe(providerRow); - await act(async () => modelRow?.click()); + await act(async () => providerRow?.click()); expect(nativeSubmenu()).not.toBeNull(); await act(async () => { document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); diff --git a/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx b/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx index f29e7fe9f5..0949e8acbd 100644 --- a/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx +++ b/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx @@ -3280,18 +3280,6 @@ const ModelSettingsPage: React.FC = () => { : 'subscriptionAuth.login')} )} - {account.management_url && ( - - )} {isLoggingIn && (