From 10a0e3ff8d9394585ebfd4daf7cfd03369a909cb Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:41:02 +0300 Subject: [PATCH] fix: use portal for showing effort dropdown --- .../chat/view/subcomponents/ChatComposer.tsx | 58 ++++++++++++++++--- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx index cf97520a..ba35dfd8 100644 --- a/src/components/chat/view/subcomponents/ChatComposer.tsx +++ b/src/components/chat/view/subcomponents/ChatComposer.tsx @@ -1,5 +1,6 @@ import { useTranslation } from 'react-i18next'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import type { ChangeEvent, ClipboardEvent, @@ -10,7 +11,7 @@ import type { RefObject, TouchEvent, } from 'react'; -import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon, Loader2, ChevronDown, Check } from 'lucide-react'; +import { ImageIcon, MessageSquareIcon, XIcon, Loader2, ChevronDown, Check } from 'lucide-react'; import { useVoiceInput } from '../../hooks/useVoiceInput'; import { useVoiceAvailable } from '../../hooks/useVoiceAvailable'; @@ -197,17 +198,40 @@ export default function ChatComposer({ const isTranscribing = voiceState === 'transcribing'; const [isEffortDropdownOpen, setIsEffortDropdownOpen] = useState(false); const effortDropdownRef = useRef(null); + const effortDropdownMenuRef = useRef(null); + const effortDropdownButtonRef = useRef(null); + const [effortDropdownPosition, setEffortDropdownPosition] = useState<{ + left: number; + top: number; + maxHeight: number; + } | null>(null); const effortOptions = useMemo( () => [{ value: 'default' }, ...availableEffortOptions], [availableEffortOptions], ); const selectedEffortLabel = effort === 'default' ? 'Default' : effort; + const updateEffortDropdownPosition = useCallback(() => { + const rect = effortDropdownButtonRef.current?.getBoundingClientRect(); + if (!rect) { + return; + } + + setEffortDropdownPosition({ + left: rect.left, + top: rect.top - 8, + maxHeight: Math.max(96, rect.top - 16), + }); + }, []); useEffect(() => { if (!isEffortDropdownOpen) return; const handlePointerDown = (event: PointerEvent) => { - if (!effortDropdownRef.current?.contains(event.target as Node)) { + const target = event.target as Node; + if ( + !effortDropdownRef.current?.contains(target) + && !effortDropdownMenuRef.current?.contains(target) + ) { setIsEffortDropdownOpen(false); } }; @@ -221,13 +245,18 @@ export default function ChatComposer({ }; document.addEventListener('pointerdown', handlePointerDown); + window.addEventListener('resize', updateEffortDropdownPosition); + window.addEventListener('scroll', updateEffortDropdownPosition, true); window.addEventListener('keydown', handleKeyDown, { capture: true }); + updateEffortDropdownPosition(); return () => { document.removeEventListener('pointerdown', handlePointerDown); + window.removeEventListener('resize', updateEffortDropdownPosition); + window.removeEventListener('scroll', updateEffortDropdownPosition, true); window.removeEventListener('keydown', handleKeyDown, { capture: true }); }; - }, [isEffortDropdownOpen]); + }, [isEffortDropdownOpen, updateEffortDropdownPosition]); // Detect if the AskUserQuestion interactive panel is active const hasQuestionPanel = pendingPermissionRequests.some( @@ -418,8 +447,12 @@ export default function ChatComposer({ {availableEffortOptions.length > 0 && (
- {isEffortDropdownOpen && ( -
+ {isEffortDropdownOpen && effortDropdownPosition && createPortal( +
{effortOptions.map((option) => { const isSelected = option.value === effort; const label = option.value === 'default' ? 'Default' : option.value; @@ -459,7 +502,8 @@ export default function ChatComposer({ ); })} -
+
, + document.body, )}
)}