diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss index de9aff99b6..4ae9b151df 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss @@ -505,6 +505,13 @@ &--compact { min-width: 0; } + + &--portal { + position: fixed; + top: auto; + right: auto; + z-index: 10000; + } } &__background-command-menu-item { diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx index 88ffa262b4..43baa02f74 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx @@ -205,4 +205,56 @@ describe('FlowChatHeader', () => { expect(onJumpToTurn).toHaveBeenCalledWith('turn-2'); expect(container.querySelector('[role="dialog"]')).not.toBeNull(); }); + + it('renders background activity menus in a portal outside the scrollable panel', () => { + const onStopBackgroundCommand = vi.fn(); + + act(() => { + root.render( + , + ); + }); + + const activityButton = container.querySelector( + '[data-testid="flowchat-header-background-activities"]', + ); + act(() => { + activityButton?.click(); + }); + + const panel = container.querySelector('.flowchat-header__background-activity-panel'); + const menuButton = panel?.querySelector( + '[aria-label="flowChatHeader.backgroundCommandActions"]', + ); + act(() => { + menuButton?.click(); + }); + + const menu = document.querySelector('[data-testid="flowchat-header-background-menu"]'); + expect(menu).not.toBeNull(); + expect(panel?.contains(menu ?? null)).toBe(false); + + act(() => { + menu?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + }); + expect(container.querySelector('.flowchat-header__background-activity-panel')).not.toBeNull(); + + const stopButton = menu?.querySelector('[role="menuitem"]'); + act(() => { + stopButton?.click(); + }); + + expect(onStopBackgroundCommand).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx index a0a4a4009e..60ed8489bf 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx @@ -5,11 +5,13 @@ */ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from 'react'; +import { createPortal } from 'react-dom'; import { Activity, Bot, ChevronDown, ChevronUp, GitPullRequest, Keyboard, List, MoreHorizontal, Search, Square, Terminal, X } from 'lucide-react'; import { Tooltip, IconButton, Input } from '@/component-library'; import { useTranslation } from 'react-i18next'; import { SessionFilesBadge } from './SessionFilesBadge'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; +import { computeFixedPopoverPosition } from '@/shared/utils/fixedPopoverViewport'; import { createReviewPlatformTab } from '@/shared/utils/tabUtils'; import './FlowChatHeader.scss'; @@ -145,8 +147,14 @@ export const FlowChatHeader: React.FC = ({ const rightActionsRef = useRef(null); const turnListRef = useRef(null); const backgroundActivityPanelRef = useRef(null); + const backgroundActivityMenuAnchorRef = useRef(null); + const backgroundActivityMenuRef = useRef(null); const activeTurnItemRef = useRef(null); const searchInputRef = useRef(null); + const [backgroundActivityMenuPosition, setBackgroundActivityMenuPosition] = useState<{ + top: number; + left: number; + } | null>(null); // Truncate long messages. const truncatedMessage = currentUserMessage.length > 50 @@ -183,6 +191,30 @@ export const FlowChatHeader: React.FC = ({ })) ), [backgroundCommands, t]); const hasNoResults = searchQuery.trim().length > 0 && searchMatchCount === 0; + const hasOpenBackgroundActivityMenu = + openBackgroundSectionMenuId !== null || + openBackgroundSubagentMenuId !== null || + openBackgroundCommandMenuId !== null; + + const updateBackgroundActivityMenuPosition = useCallback(() => { + const anchor = backgroundActivityMenuAnchorRef.current; + if (!anchor) return; + + const menu = backgroundActivityMenuRef.current; + const { top, left } = computeFixedPopoverPosition( + anchor.getBoundingClientRect(), + menu?.offsetWidth ?? 200, + menu?.offsetHeight ?? 96, + 4, + 8, + ); + setBackgroundActivityMenuPosition({ top, left }); + }, []); + + const prepareBackgroundActivityMenu = useCallback((anchor: HTMLButtonElement) => { + backgroundActivityMenuAnchorRef.current = anchor; + updateBackgroundActivityMenuPosition(); + }, [updateBackgroundActivityMenuPosition]); useEffect(() => { if (!isTurnListOpen && !isBackgroundActivityPanelOpen) return; @@ -191,7 +223,8 @@ export const FlowChatHeader: React.FC = ({ const target = event.target as Node; if ( !turnListRef.current?.contains(target) && - !backgroundActivityPanelRef.current?.contains(target) + !backgroundActivityPanelRef.current?.contains(target) && + !backgroundActivityMenuRef.current?.contains(target) ) { setIsTurnListOpen(false); setIsBackgroundActivityPanelOpen(false); @@ -220,6 +253,28 @@ export const FlowChatHeader: React.FC = ({ }; }, [isBackgroundActivityPanelOpen, isTurnListOpen]); + useLayoutEffect(() => { + if (!hasOpenBackgroundActivityMenu) { + setBackgroundActivityMenuPosition(null); + return; + } + + updateBackgroundActivityMenuPosition(); + window.addEventListener('resize', updateBackgroundActivityMenuPosition); + window.addEventListener('scroll', updateBackgroundActivityMenuPosition, true); + + return () => { + window.removeEventListener('resize', updateBackgroundActivityMenuPosition); + window.removeEventListener('scroll', updateBackgroundActivityMenuPosition, true); + }; + }, [ + hasOpenBackgroundActivityMenu, + openBackgroundCommandMenuId, + openBackgroundSectionMenuId, + openBackgroundSubagentMenuId, + updateBackgroundActivityMenuPosition, + ]); + const prevSearchOpenRequestRef = useRef(0); useEffect(() => { if (searchOpenRequest > 0 && searchOpenRequest !== prevSearchOpenRequestRef.current) { @@ -361,6 +416,11 @@ export const FlowChatHeader: React.FC = ({ ) => { event.preventDefault(); event.stopPropagation(); + if (openBackgroundSubagentMenuId === subagent.sessionId) { + setBackgroundActivityMenuPosition(null); + } else { + prepareBackgroundActivityMenu(event.currentTarget); + } setOpenBackgroundSectionMenuId(null); setOpenBackgroundCommandMenuId(null); setOpenBackgroundSubagentMenuId(previous => previous === subagent.sessionId ? null : subagent.sessionId); @@ -387,6 +447,11 @@ export const FlowChatHeader: React.FC = ({ const handleCommandSectionMenuToggle = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); + if (openBackgroundSectionMenuId === 'commands') { + setBackgroundActivityMenuPosition(null); + } else { + prepareBackgroundActivityMenu(event.currentTarget); + } setOpenBackgroundSubagentMenuId(null); setOpenBackgroundCommandMenuId(null); setOpenBackgroundSectionMenuId(previous => previous === 'commands' ? null : 'commands'); @@ -395,6 +460,11 @@ export const FlowChatHeader: React.FC = ({ const handleSubagentSectionMenuToggle = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); + if (openBackgroundSectionMenuId === 'subagents') { + setBackgroundActivityMenuPosition(null); + } else { + prepareBackgroundActivityMenu(event.currentTarget); + } setOpenBackgroundSubagentMenuId(null); setOpenBackgroundCommandMenuId(null); setOpenBackgroundSectionMenuId(previous => previous === 'subagents' ? null : 'subagents'); @@ -411,6 +481,11 @@ export const FlowChatHeader: React.FC = ({ ) => { event.preventDefault(); event.stopPropagation(); + if (openBackgroundCommandMenuId === command.execSessionKey) { + setBackgroundActivityMenuPosition(null); + } else { + prepareBackgroundActivityMenu(event.currentTarget); + } setOpenBackgroundSectionMenuId(null); setOpenBackgroundSubagentMenuId(null); setOpenBackgroundCommandMenuId(previous => previous === command.execSessionKey ? null : command.execSessionKey); @@ -463,11 +538,14 @@ export const FlowChatHeader: React.FC = ({ >