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}
)
}