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 }) => (
-
-);
-
// 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 = ({
>
-
+ {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 && (
-
+ <>
+
+
+ >
)}
@@ -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 && (