From 6b55ace2d72b24113888092998ae2a65fef8e4e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 8 May 2026 10:43:09 +0800 Subject: [PATCH] Prevent model selector clipping in task modals The model selector dropdown was rendered inside the modal scroll container. When the task form sat near the top of the dialog, the upward-opening menu could be clipped by the container and become impossible to scroll into view. The selector now portals the menu to the document body and positions it against the trigger with viewport-aware fixed coordinates. Constraint: The same selector is used by chat/session controls and scheduled-task controls, so the fix must preserve existing runtime selection behavior. Rejected: Increase modal scroll padding | still leaves the dropdown coupled to a clipping parent and breaks in other overflow containers. Confidence: high Scope-risk: narrow Directive: Keep floating selector content outside scroll-clipping parents; update viewport positioning if the modal layout changes. Tested: cd desktop && bun run test -- src/components/controls/ModelSelector.test.tsx src/components/tasks/NewTaskModal.test.tsx Tested: bun run check:desktop Tested: git diff --check Not-tested: Manual packaged desktop app check. --- .../controls/ModelSelector.test.tsx | 43 ++ .../src/components/controls/ModelSelector.tsx | 383 +++++++++++------- 2 files changed, 277 insertions(+), 149 deletions(-) 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}
) }