From 3b93e1036febdf02655801f4d59fb25d200db422 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 29 Jul 2026 22:23:02 -0700 Subject: [PATCH] fix(web): align dispatch popover with trigger right edge (#1877) Switch from createPortal + position:fixed to inline position:absolute, matching the permission-mode popover pattern. Fixes right-alignment, eliminates the horizontal scrollbar on hover, and prevents the menu from occasionally rendering outside the app window. --- .../dispatch/DispatchTargetPicker.scss | 10 +++-- .../dispatch/DispatchTargetPicker.tsx | 38 ++----------------- 2 files changed, 10 insertions(+), 38 deletions(-) diff --git a/src/web-ui/src/features/dispatch/DispatchTargetPicker.scss b/src/web-ui/src/features/dispatch/DispatchTargetPicker.scss index dde4cb8c90..ca7688bc42 100644 --- a/src/web-ui/src/features/dispatch/DispatchTargetPicker.scss +++ b/src/web-ui/src/features/dispatch/DispatchTargetPicker.scss @@ -1,6 +1,7 @@ @use '../../component-library/styles/tokens' as *; .dispatch-target-picker { + position: relative; display: inline-flex; min-width: 0; align-items: center; @@ -54,8 +55,10 @@ } &__menu { - position: fixed; - z-index: 1200; + position: absolute; + right: 0; + bottom: calc(100% + 7px); + z-index: 10; box-sizing: border-box; display: flex; width: min(300px, calc(100vw - 24px)); @@ -63,7 +66,8 @@ flex-direction: column; gap: 4px; padding: 6px; - overflow: auto; + overflow-x: hidden; + overflow-y: auto; border: 1px solid var(--border-subtle); border-radius: $size-radius-base; background: var(--color-bg-elevated); diff --git a/src/web-ui/src/features/dispatch/DispatchTargetPicker.tsx b/src/web-ui/src/features/dispatch/DispatchTargetPicker.tsx index 248aec3b7a..72ac6bd37d 100644 --- a/src/web-ui/src/features/dispatch/DispatchTargetPicker.tsx +++ b/src/web-ui/src/features/dispatch/DispatchTargetPicker.tsx @@ -1,13 +1,11 @@ import React, { lazy, Suspense, - useCallback, useEffect, useMemo, useRef, useState, } from 'react'; -import { createPortal } from 'react-dom'; import { Check, ChevronDown, @@ -21,7 +19,6 @@ import { Tooltip } from '@/component-library'; import { SSHConnectionDialog } from '@/features/ssh-remote/SSHConnectionDialog'; import { useAccountLoginState } from '@/infrastructure/account/useAccountLoginState'; import { useI18n } from '@/infrastructure/i18n'; -import { computeFixedPopoverPosition } from '@/shared/utils/fixedPopoverViewport'; import { DispatchInstallDialog } from './DispatchInstallDialog'; import type { DispatchSelection, @@ -54,10 +51,7 @@ export const DispatchTargetPicker: React.FC = ({ }) => { const { t } = useI18n('flow-chat'); const rootRef = useRef(null); - const triggerRef = useRef(null); - const menuRef = useRef(null); const [open, setOpen] = useState(false); - const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); const [configureTarget, setConfigureTarget] = useState(null); const [sshDialogOpen, setSshDialogOpen] = useState(false); const [accountDialogOpen, setAccountDialogOpen] = useState(false); @@ -71,32 +65,10 @@ export const DispatchTargetPicker: React.FC = ({ ? t('chatInput.dispatch.locked', { target: displayLabel }) : t('chatInput.dispatch.current', { target: displayLabel }); - const updatePosition = useCallback(() => { - const rect = triggerRef.current?.getBoundingClientRect(); - if (!rect) return; - const width = menuRef.current?.offsetWidth ?? 300; - const height = menuRef.current?.offsetHeight ?? 340; - setMenuPosition(computeFixedPopoverPosition(rect, width, height, 7, 8)); - }, []); - - useEffect(() => { - if (!open) return; - updatePosition(); - const frame = requestAnimationFrame(updatePosition); - window.addEventListener('resize', updatePosition); - window.addEventListener('scroll', updatePosition, true); - return () => { - cancelAnimationFrame(frame); - window.removeEventListener('resize', updatePosition); - window.removeEventListener('scroll', updatePosition, true); - }; - }, [open, updatePosition]); - useEffect(() => { if (!open) return; const handlePointerDown = (event: PointerEvent) => { - const node = event.target as Node; - if (!rootRef.current?.contains(node) && !menuRef.current?.contains(node)) { + if (!rootRef.current?.contains(event.target as Node)) { setOpen(false); } }; @@ -126,13 +98,11 @@ export const DispatchTargetPicker: React.FC = ({ [targets], ); - const menu = open ? createPortal( + const menu = open ? (
@@ -272,8 +242,7 @@ export const DispatchTargetPicker: React.FC = ({ {t('chatInput.dispatch.addSsh')} -
, - document.body, +
) : null; return ( @@ -281,7 +250,6 @@ export const DispatchTargetPicker: React.FC = ({