diff --git a/desktop/src/components/controls/ModelSelector.test.tsx b/desktop/src/components/controls/ModelSelector.test.tsx index 39e89aaf..7c98d126 100644 --- a/desktop/src/components/controls/ModelSelector.test.tsx +++ b/desktop/src/components/controls/ModelSelector.test.tsx @@ -117,4 +117,47 @@ describe('ModelSelector', () => { modelId: 'provider-fast', }) }) + + it('portals the dropdown outside clipping containers and positions it below the trigger', async () => { + useSettingsStore.setState({ + locale: 'en', + availableModels: MODELS, + currentModel: MODELS[0], + }) + + const { container } = render( +
+ +
, + ) + + const trigger = screen.getByRole('button', { name: /alpha/i }) + Object.defineProperty(trigger.parentElement, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + top: 120, + right: 520, + bottom: 150, + left: 240, + width: 280, + height: 30, + x: 240, + y: 120, + toJSON: () => {}, + }), + }) + + await act(async () => { + fireEvent.click(trigger) + await Promise.resolve() + }) + + const dropdown = screen.getByTestId('model-selector-dropdown') + expect(container.contains(dropdown)).toBe(false) + expect(document.body.contains(dropdown)).toBe(true) + expect(dropdown.className).toContain('fixed') + expect(dropdown.style.top).toBe('158px') + expect(dropdown.style.left).toBe('160px') + expect(dropdown.style.width).toBe('360px') + }) }) diff --git a/desktop/src/components/controls/ModelSelector.tsx b/desktop/src/components/controls/ModelSelector.tsx index 806a6501..606eaccb 100644 --- a/desktop/src/components/controls/ModelSelector.tsx +++ b/desktop/src/components/controls/ModelSelector.tsx @@ -1,4 +1,5 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import { OFFICIAL_DEFAULT_MODEL_ID, OFFICIAL_MODELS } from '../../constants/modelCatalog' import { useTranslation } from '../../i18n' import { useChatStore } from '../../stores/chatStore' @@ -26,6 +27,19 @@ type Props = { compact?: boolean } +type DropdownPosition = { + top: number + left: number + width: number + maxHeight: number +} + +const DROPDOWN_WIDTH = 360 +const DROPDOWN_GAP = 8 +const VIEWPORT_MARGIN = 16 +const DROPDOWN_MAX_HEIGHT = 420 +const DROPDOWN_MIN_HEIGHT = 180 + function officialChoices(availableModels: ModelInfo[], isDefault: boolean, officialName: string): ProviderChoice { return { providerId: null, @@ -131,7 +145,9 @@ export function ModelSelector({ runtimeKey ? state.selections[runtimeKey] : undefined, ) const [open, setOpen] = useState(false) + const [dropdownPosition, setDropdownPosition] = useState(null) const ref = useRef(null) + const dropdownRef = useRef(null) const requestedProvidersRef = useRef(false) const EFFORT_OPTIONS: { value: EffortLevel; label: string }[] = [ @@ -155,7 +171,14 @@ export function ModelSelector({ useEffect(() => { if (!open) return const handleClick = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + const target = e.target as Node + if ( + ref.current && + !ref.current.contains(target) && + !dropdownRef.current?.contains(target) + ) { + setOpen(false) + } } const handleEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) @@ -168,6 +191,55 @@ export function ModelSelector({ } }, [open]) + const updateDropdownPosition = useCallback(() => { + const anchor = ref.current + if (!anchor) return + + const rect = anchor.getBoundingClientRect() + const viewportWidth = window.innerWidth || document.documentElement.clientWidth + const viewportHeight = window.innerHeight || document.documentElement.clientHeight + const width = Math.min(DROPDOWN_WIDTH, Math.max(0, viewportWidth - VIEWPORT_MARGIN * 2)) + const left = Math.min( + Math.max(VIEWPORT_MARGIN, rect.right - width), + Math.max(VIEWPORT_MARGIN, viewportWidth - width - VIEWPORT_MARGIN), + ) + const spaceBelow = viewportHeight - rect.bottom - DROPDOWN_GAP - VIEWPORT_MARGIN + const spaceAbove = rect.top - DROPDOWN_GAP - VIEWPORT_MARGIN + const placeBelow = spaceBelow >= DROPDOWN_MIN_HEIGHT || spaceBelow >= spaceAbove + const availableHeight = Math.max( + DROPDOWN_MIN_HEIGHT, + placeBelow ? spaceBelow : spaceAbove, + ) + const maxHeight = Math.min(DROPDOWN_MAX_HEIGHT, availableHeight) + + setDropdownPosition({ + top: placeBelow + ? rect.bottom + DROPDOWN_GAP + : Math.max(VIEWPORT_MARGIN, rect.top - DROPDOWN_GAP - maxHeight), + left, + width, + maxHeight, + }) + }, []) + + useLayoutEffect(() => { + if (!open) { + setDropdownPosition(null) + return + } + updateDropdownPosition() + }, [open, updateDropdownPosition]) + + useEffect(() => { + if (!open) return + window.addEventListener('resize', updateDropdownPosition) + window.addEventListener('scroll', updateDropdownPosition, true) + return () => { + window.removeEventListener('resize', updateDropdownPosition) + window.removeEventListener('scroll', updateDropdownPosition, true) + } + }, [open, updateDropdownPosition]) + const roleLabels = useMemo( () => ({ main: t('settings.providers.mainModel'), @@ -234,6 +306,165 @@ export function ModelSelector({ setOpen(false) } + const dropdown = open && dropdownPosition + ? createPortal( +
+
+
+ {t('model.configuration')} +
+ + {isRuntimeScoped ? ( +
+ {providerChoices.map((choice) => ( +
+
+ + {choice.providerName} + + {choice.isDefault && ( + + {t('settings.providers.default')} + + )} +
+ +
+ {choice.models.map((model) => { + const isSelected = + activeRuntimeSelection?.providerId === choice.providerId && + activeRuntimeSelection.modelId === model.id + return ( + + ) + })} +
+
+ ))} +
+ ) : ( +
+ {availableModels.map((model) => { + const isSelected = model.id === selectedModel?.id + return ( + + ) + })} +
+ )} +
+ + {!isControlled && !isRuntimeScoped && ( +
+
+ {t('model.effort')} +
+
+ {EFFORT_OPTIONS.map((opt) => { + const isSelected = opt.value === effortLevel + return ( + + ) + })} +
+
+ )} +
, + document.body, + ) + : null + return (
- - {open && ( -
-
-
- {t('model.configuration')} -
- - {isRuntimeScoped ? ( -
- {providerChoices.map((choice) => ( -
-
- - {choice.providerName} - - {choice.isDefault && ( - - {t('settings.providers.default')} - - )} -
- -
- {choice.models.map((model) => { - const isSelected = - activeRuntimeSelection?.providerId === choice.providerId && - activeRuntimeSelection.modelId === model.id - return ( - - ) - })} -
-
- ))} -
- ) : ( -
- {availableModels.map((model) => { - const isSelected = model.id === selectedModel?.id - return ( - - ) - })} -
- )} -
- - {!isControlled && !isRuntimeScoped && ( -
-
- {t('model.effort')} -
-
- {EFFORT_OPTIONS.map((opt) => { - const isSelected = opt.value === effortLevel - return ( - - ) - })} -
-
- )} -
- )} + {dropdown}
) }