From 47b5275fcb2c0f66f57db556827901472aefc968 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 4 Aug 2026 01:27:46 -0700 Subject: [PATCH] fix(ui): prevent anchored overlays from clipping --- .../src/app/components/NavPanel/NavPanel.scss | 10 +- .../components/PersistentFooterActions.tsx | 28 +- .../scheduled-jobs/ScheduledJobsView.scss | 17 +- .../scheduled-jobs/ScheduledJobsView.tsx | 2 + .../src/app/scenes/agents/AgentsScene.tsx | 2 + .../app/scenes/my-agent/InsightsScene.scss | 9 +- .../src/app/scenes/my-agent/InsightsScene.tsx | 2 + .../src/app/scenes/pages/PagesScene.scss | 3 +- .../views/AssistantAvatarPicker.test.tsx | 6 +- .../profile/views/AssistantAvatarPicker.tsx | 27 +- .../app/scenes/profile/views/NurseryView.scss | 10 +- src/web-ui/src/app/scenes/shell/ShellNav.scss | 11 +- src/web-ui/src/app/scenes/shell/ShellNav.tsx | 33 +- .../shell/hooks/useShellNavMenuState.ts | 4 + .../components/Select/Select.scss | 78 ++-- .../components/Select/Select.test.tsx | 108 ++++- .../components/Select/Select.tsx | 403 +++++++++--------- .../dispatch/DispatchTargetPicker.scss | 11 +- .../dispatch/DispatchTargetPicker.test.tsx | 108 +++++ .../dispatch/DispatchTargetPicker.tsx | 30 +- .../src/flow_chat/components/ChatInput.scss | 22 +- .../src/flow_chat/components/ChatInput.tsx | 118 ++++- .../components/ChatInputWorkspaceStrip.scss | 12 +- .../ChatInputWorkspaceStrip.test.tsx | 22 +- .../components/ChatInputWorkspaceStrip.tsx | 33 +- .../ChatInputWorkspaceStripLayout.test.ts | 2 +- .../components/FileMentionPicker.scss | 11 + .../components/FileMentionPicker.tsx | 42 +- .../FileMentionPickerOverlay.test.tsx | 78 ++++ .../flow_chat/components/ModelSelector.scss | 7 +- .../src/flow_chat/components/WelcomePanel.css | 14 +- .../components/WelcomePanel.test.tsx | 15 + .../src/flow_chat/components/WelcomePanel.tsx | 38 +- .../components/modern/FlowChatHeader.scss | 16 +- .../components/modern/FlowChatHeader.test.tsx | 26 +- .../components/modern/FlowChatHeader.tsx | 32 +- .../components/modern/ModelRoundItem.scss | 10 +- .../components/modern/ModelRoundItem.tsx | 22 +- .../components/modern/SessionFilesBadge.scss | 29 +- .../modern/SessionFilesBadge.test.tsx | 22 +- .../components/modern/SessionFilesBadge.tsx | 66 ++- .../components/modern/SessionTreePopover.scss | 14 +- .../modern/SessionTreePopover.test.tsx | 19 +- .../components/modern/SessionTreePopover.tsx | 26 +- .../modern/UserMessageEditComposer.tsx | 4 +- .../overlayClippingContract.test.ts | 44 ++ .../components/session-menu/SessionMenu.scss | 26 +- .../session-menu/SessionMenu.test.tsx | 105 +++++ .../components/session-menu/SessionMenu.tsx | 28 +- .../components/toolbar-mode/ToolbarMode.scss | 14 +- .../components/toolbar-mode/ToolbarMode.tsx | 24 +- .../tool-cards/ToolTimeoutIndicator.scss | 14 +- .../tool-cards/ToolTimeoutIndicator.test.tsx | 27 ++ .../tool-cards/ToolTimeoutIndicator.tsx | 41 +- .../config/components/AIFeaturesConfig.scss | 8 +- .../config/components/AIModelConfig.scss | 34 +- .../config/components/AIModelConfig.tsx | 2 + .../config/components/DefaultModelConfig.tsx | 3 + .../components/ExternalSourcesConfig.test.tsx | 5 +- .../components/ModelSelectPresentation.scss | 9 +- .../config/components/SessionConfig.tsx | 1 + .../config/components/SubagentModelConfig.tsx | 1 + .../utils/useAnchoredPopoverPosition.ts | 177 ++++++++ 63 files changed, 1685 insertions(+), 480 deletions(-) create mode 100644 src/web-ui/src/features/dispatch/DispatchTargetPicker.test.tsx create mode 100644 src/web-ui/src/flow_chat/components/FileMentionPickerOverlay.test.tsx create mode 100644 src/web-ui/src/flow_chat/components/overlayClippingContract.test.ts create mode 100644 src/web-ui/src/flow_chat/components/session-menu/SessionMenu.test.tsx create mode 100644 src/web-ui/src/shared/utils/useAnchoredPopoverPosition.ts diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index ab6e6ef763..2abf56c9d0 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -1730,16 +1730,20 @@ $_section-header-height: 24px; } .bitfun-nav-panel__footer-menu { - position: absolute; - bottom: calc(100% + 6px); - left: 0; + position: fixed; min-width: 148px; + max-width: calc(100vw - 16px); + max-height: calc(100vh - 16px); + overflow-y: auto; padding: $size-gap-1; background: var(--bf-appearance-token-color-bg-elevated); border: 1px solid var(--bf-appearance-token-border-subtle); border-radius: $size-radius-base; box-shadow: 0 4px 12px var(--bf-appearance-token-color-overlay-black-30); z-index: 9999; + color: var(--bf-appearance-token-color-text-primary); + font-family: var(--bf-appearance-token-font-family-sans); + font-size: var(--bf-appearance-token-font-size-sm); transform-origin: bottom left; animation: bitfun-footer-menu-in $motion-fast $easing-decelerate forwards; diff --git a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx index 6c32276187..1b1cce778b 100644 --- a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx @@ -1,4 +1,5 @@ -import React, { lazy, Suspense, useState, useCallback, useEffect } from 'react'; +import React, { lazy, Suspense, useState, useCallback, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; import { Settings, Info, @@ -30,6 +31,8 @@ import { getRemoteConnectDisclaimerAgreed, setRemoteConnectDisclaimerAgreed, } from '../../RemoteConnectDialog/remoteConnectDisclaimerStorage'; +import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; +import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; const RemoteConnectDialog = lazy(() => import('../../RemoteConnectDialog')); const AboutDialog = lazy(() => @@ -70,6 +73,16 @@ const PersistentFooterActions: React.FC = () => { const [menuOpen, setMenuOpen] = useState(false); const [menuClosing, setMenuClosing] = useState(false); + const menuTriggerRef = useRef(null); + const menuPopoverRef = useRef(null); + const menuLayout = useAnchoredPopoverPosition({ + open: menuOpen, + anchorRef: menuTriggerRef, + popoverRef: menuPopoverRef, + preferredPlacement: 'top', + alignment: 'start', + gap: 6, + }); const [showAbout, setShowAbout] = useState(false); const [showRemoteConnect, setShowRemoteConnect] = useState(false); const [remoteInitialGroup, setRemoteInitialGroup] = useState<'network' | 'bot' | 'account' | undefined>(undefined); @@ -183,6 +196,7 @@ const PersistentFooterActions: React.FC = () => {
- {menuOpen && ( + {menuOpen && createPortal( <>
- + , + getAppearanceOverlayHost(), )}
diff --git a/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsView.scss b/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsView.scss index 6123118239..3383de22cd 100644 --- a/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsView.scss +++ b/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsView.scss @@ -402,18 +402,6 @@ color: var(--bf-appearance-token-color-text-muted); } - &__agent-select { - .select__dropdown { - max-height: 156px; - } - } - - &__session-select { - .select__dropdown { - max-height: 156px; - } - } - &__warning { font-size: var(--bf-appearance-token-font-size-xs); color: var(--bf-appearance-token-color-error); @@ -478,6 +466,11 @@ } } +.asv__agent-select-dropdown, +.asv__session-select-dropdown { + max-height: min(156px, calc(100vh - 16px)); +} + @media (max-width: 760px) { .asv { &__form-row { diff --git a/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsView.tsx b/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsView.tsx index 0e54796ebd..23b1abc13f 100644 --- a/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsView.tsx +++ b/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsView.tsx @@ -1097,6 +1097,7 @@ const ScheduledJobsView: React.FC = ({ searchable clearable className="asv__session-select" + dropdownClassName="asv__session-select-dropdown" onChange={value => { setValidationErrors(current => ({ ...current, sessionId: false })); setDraft(c => ({ ...c, sessionId: String(value) })); @@ -1118,6 +1119,7 @@ const ScheduledJobsView: React.FC = ({ error={validationErrors.agentType} disabled={workspaceKind === WorkspaceKind.Assistant} className="asv__agent-select" + dropdownClassName="asv__agent-select-dropdown" renderOption={option => (
{option.label} diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx index 05224e96ea..d996d21099 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx @@ -1116,6 +1116,8 @@ const AgentsHomeView: React.FC = () => { size="small" searchable className="bitfun-agents-scene__subagent-model-select model-select-presentation__select" + dropdownClassName="model-select-presentation__dropdown" + dropdownMode="inline" options={subagentModelOptions} value={selectedSubagentModelValue} onChange={(value) => void handleSubagentModelChange(value)} diff --git a/src/web-ui/src/app/scenes/my-agent/InsightsScene.scss b/src/web-ui/src/app/scenes/my-agent/InsightsScene.scss index 01bf30666b..4e2be848af 100644 --- a/src/web-ui/src/app/scenes/my-agent/InsightsScene.scss +++ b/src/web-ui/src/app/scenes/my-agent/InsightsScene.scss @@ -145,10 +145,10 @@ $ins-label: 12px; padding-bottom: 5px; } - .select__dropdown { - width: 340px; - right: auto; - } +} + +.insights-scene__model-select-dropdown { + width: min(340px, calc(100vw - 48px)); } .insights-model-select { @@ -684,7 +684,6 @@ $ins-label: 12px; width: 100%; flex: 0 0 100%; - .select__dropdown { width: 100%; right: 0; } } .insights-scene__control-label { width: 100%; } .insights-meta-card__top { align-items: flex-start; flex-direction: column; } diff --git a/src/web-ui/src/app/scenes/my-agent/InsightsScene.tsx b/src/web-ui/src/app/scenes/my-agent/InsightsScene.tsx index 36044a4149..af0f53ec11 100644 --- a/src/web-ui/src/app/scenes/my-agent/InsightsScene.tsx +++ b/src/web-ui/src/app/scenes/my-agent/InsightsScene.tsx @@ -248,6 +248,8 @@ const InsightsScene: React.FC = () => { {t('insights.modelLabel')} ); + }); + const trigger = container.querySelector('.select__trigger'); + await act(async () => trigger?.click()); + const dropdown = document.querySelector('.select__dropdown'); + expect(dropdown?.style.top).toBe('140px'); + + triggerTop = 300; + await act(async () => { + window.dispatchEvent(new Event('scroll')); + await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); + }); + + expect(dropdown?.style.top).toBe('340px'); + }); + it('keeps grouped order stable and skips disabled options during keyboard navigation', async () => { const onChange = vi.fn(); Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { @@ -192,8 +243,8 @@ describe('Select', () => { await Promise.resolve(); }); - const listbox = container.querySelector('[role="listbox"]') as HTMLElement; - const options = Array.from(container.querySelectorAll('[role="option"]')); + const listbox = document.querySelector('[role="listbox"]') as HTMLElement; + const options = Array.from(document.querySelectorAll('[role="option"]')); expect(options.map((option) => option.textContent)).toEqual([ 'Disabled ungrouped', 'Group A choice', @@ -202,7 +253,7 @@ describe('Select', () => { expect(trigger.getAttribute('aria-controls')).toBe(listbox.id); expect(trigger.getAttribute('aria-activedescendant')).toBe(options[1].id); expect(options[1].className).toContain('select__option--highlighted'); - expect(container.querySelector('[role="group"]')?.getAttribute('aria-label')).toBe('Group A'); + expect(document.querySelector('[role="group"]')?.getAttribute('aria-label')).toBe('Group A'); await act(async () => { trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); @@ -229,8 +280,8 @@ describe('Select', () => { }); const trigger = container.querySelector('.select__trigger') as HTMLElement; await act(async () => trigger.click()); - const input = container.querySelector('.select__search-input') as HTMLInputElement; - const listbox = container.querySelector('[role="listbox"]') as HTMLElement; + const input = document.querySelector('.select__search-input') as HTMLInputElement; + const listbox = document.querySelector('[role="listbox"]') as HTMLElement; await act(async () => { input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); @@ -240,7 +291,44 @@ describe('Select', () => { expect(input.getAttribute('aria-controls')).toBe(listbox.id); expect(input.getAttribute('aria-label')).toBe('Find a choice'); expect(input.getAttribute('aria-activedescendant')).toBe( - container.querySelectorAll('[role="option"]')[1].id, + document.querySelectorAll('[role="option"]')[1].id, ); }); + + it('keeps portalled interactions inside the Select and closes on a true outside press', async () => { + await act(async () => { + root.render(, + ); + }); + const trigger = container.querySelector('.select__trigger'); + await act(async () => trigger?.click()); + + const dropdown = container.querySelector('.select__dropdown'); + expect(dropdown).not.toBeNull(); + expect(dropdown?.className).toContain('select__dropdown--inline'); + expect(dropdown?.style.position).toBe(''); + }); }); diff --git a/src/web-ui/src/component-library/components/Select/Select.tsx b/src/web-ui/src/component-library/components/Select/Select.tsx index af9fbc59a7..12e152fca5 100644 --- a/src/web-ui/src/component-library/components/Select/Select.tsx +++ b/src/web-ui/src/component-library/components/Select/Select.tsx @@ -6,11 +6,13 @@ import React, { useState, useRef, useEffect, - useLayoutEffect, useMemo, useCallback, } from 'react'; +import { createPortal } from 'react-dom'; +import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { useI18n } from '@/infrastructure/i18n'; +import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import './Select.scss'; export interface SelectOption { @@ -56,6 +58,12 @@ export interface SelectProps extends Omit, triggerAriaLabel?: string; triggerAriaLabelledBy?: string; triggerAriaDescribedBy?: string; + /** Additional class applied directly to the dropdown, including when portalled. */ + dropdownClassName?: string; + /** Overlay escapes clipping by default; inline is reserved for layout-expanding selectors. */ + dropdownMode?: 'overlay' | 'inline'; + /** Match the trigger width, preserving the historical Select default. */ + dropdownMatchTriggerWidth?: boolean; } export const Select: React.FC = ({ @@ -90,6 +98,9 @@ export const Select: React.FC = ({ triggerAriaLabel, triggerAriaLabelledBy, triggerAriaDescribedBy, + dropdownClassName = '', + dropdownMode = 'overlay', + dropdownMatchTriggerWidth = true, ...rootProps }) => { const { t } = useI18n('components'); @@ -104,7 +115,6 @@ export const Select: React.FC = ({ const resolvedEmptyText = emptyText ?? t('select.emptyText'); const resolvedCustomValueHint = customValueHint ?? t('select.customValueHint'); const [isOpen, setIsOpen] = useState(false); - const [resolvedPlacement, setResolvedPlacement] = useState<'bottom' | 'top'>(placement); const [selectedValue, setSelectedValue] = useState( value !== undefined ? value : defaultValue !== undefined ? defaultValue : multiple ? [] : '' ); @@ -113,6 +123,7 @@ export const Select: React.FC = ({ const hasMountedRef = useRef(false); const selectRef = useRef(null); + const triggerRef = useRef(null); const searchInputRef = useRef(null); const dropdownRef = useRef(null); const isKeyboardNavigation = useRef(false); @@ -133,43 +144,18 @@ export const Select: React.FC = ({ ); }, [options, searchQuery, searchable]); - useLayoutEffect(() => { - if (!isOpen) { - setResolvedPlacement(placement); - return; - } - - const selectElement = selectRef.current; - const dropdownElement = dropdownRef.current; - if (!selectElement || !dropdownElement || typeof window === 'undefined') { - setResolvedPlacement(placement); - return; - } - - const triggerRect = selectElement.getBoundingClientRect(); - const dropdownHeight = dropdownElement.offsetHeight || dropdownElement.scrollHeight || 240; - const spaceBelow = window.innerHeight - triggerRect.bottom; - const spaceAbove = triggerRect.top; - - let nextPlacement: 'bottom' | 'top' = placement; - if (placement === 'bottom' && spaceBelow < dropdownHeight && spaceAbove > spaceBelow) { - nextPlacement = 'top'; - } else if (placement === 'top' && spaceAbove < dropdownHeight && spaceBelow > spaceAbove) { - nextPlacement = 'bottom'; - } - - setResolvedPlacement(nextPlacement); - }, [ - isOpen, - placement, - options.length, - searchable, - multiple, - showSelectAll, - allowCustomValue, - searchQuery, - filteredOptions.length, - ]); + const dropdownLayout = useAnchoredPopoverPosition({ + open: isOpen && dropdownMode === 'overlay', + anchorRef: triggerRef, + popoverRef: dropdownRef, + preferredPlacement: placement, + gap: 0, + matchAnchorWidth: dropdownMatchTriggerWidth, + layoutRevision: `${filteredOptions.length}:${searchQuery}:${loading}`, + }); + const resolvedPlacement = dropdownMode === 'inline' + ? 'bottom' + : dropdownLayout?.placement ?? placement; const groupedOptions = useMemo(() => { const groups: { [key: string]: SelectOption[] } = {}; @@ -365,7 +351,12 @@ export const Select: React.FC = ({ useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (selectRef.current && !selectRef.current.contains(event.target as Node)) { + const target = event.target as Node; + if ( + selectRef.current + && !selectRef.current.contains(target) + && !dropdownRef.current?.contains(target) + ) { if (allowCustomValue && !multiple && searchQuery.trim()) { const trimmedValue = searchQuery.trim(); const existingOption = options.find(opt => @@ -531,11 +522,175 @@ export const Select: React.FC = ({ ); }; + const dropdownNode = ( +
+ {searchable && ( +
+ = 0 + ? `${listboxId}-option-${highlightedIndex}` + : undefined} + placeholder={resolvedSearchPlaceholder} + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (highlightedIndex >= 0 && highlightedIndex < displayOptions.length) { + handleSelect(displayOptions[highlightedIndex]); + } else if (allowCustomValue && searchQuery.trim()) { + handleCustomValueSubmit(); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + setIsOpen(false); + setSearchQuery(''); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + isKeyboardNavigation.current = true; + setHighlightedIndex((previous) => moveHighlight(previous, 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + isKeyboardNavigation.current = true; + setHighlightedIndex((previous) => moveHighlight( + previous < 0 ? displayOptions.length : previous, + -1, + )); + } + }} + data-bf-component="select" + data-bf-part="searchInput" + /> + {searchQuery && ( + + )} +
+ )} + + {multiple && showSelectAll && filteredOptions.length > 0 && ( +
!opt.disabled).every(opt => isSelected(opt.value))} + > + !opt.disabled).every(opt => isSelected(opt.value)) + ? 'select__checkbox--checked' : '' + }`}> + {filteredOptions.filter(opt => !opt.disabled).every(opt => isSelected(opt.value)) && '✓'} + + {t('select.selectAll')} +
+ )} + +
+ {filteredOptions.length === 0 ? ( + loading ? ( +
+
+ ) : allowCustomValue && searchQuery.trim() ? ( +
handleCustomValueSubmit()}> + "{searchQuery.trim()}" + {resolvedCustomValueHint} +
+ ) : ( +
{resolvedEmptyText}
+ ) + ) : groupedOptions.hasGroups ? ( + (() => { + let globalIndex = 0; + return ( + <> + {groupedOptions.ungrouped.map((option) => renderOptionItem(option, globalIndex++))} + {Object.entries(groupedOptions.groups).map(([groupName, groupOptions]) => ( +
+
{groupName}
+ {groupOptions.map((option) => renderOptionItem(option, globalIndex++))} +
+ ))} + + ); + })() + ) : ( + <> + {filteredOptions.map((option, index) => renderOptionItem(option, index))} + {allowCustomValue && searchQuery.trim() + && !filteredOptions.some(opt => ( + opt.label.toLowerCase() === searchQuery.trim().toLowerCase() + || String(opt.value).toLowerCase() === searchQuery.trim().toLowerCase() + )) && ( +
handleCustomValueSubmit()}> + "{searchQuery.trim()}" + {resolvedCustomValueHint} +
+ )} + + )} +
+
+ ); + return (
{label &&
{label}
} - +
!disabled && setIsOpen(!isOpen)} onKeyDown={handleKeyDown} @@ -558,7 +713,7 @@ export const Select: React.FC = ({ data-bf-part="trigger" > {renderSelectedValue()} - +
{loading && ( @@ -576,166 +731,10 @@ export const Select: React.FC = ({
- {isOpen && ( -
- {searchable && ( -
- = 0 - ? `${listboxId}-option-${highlightedIndex}` - : undefined} - placeholder={resolvedSearchPlaceholder} - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - onClick={(e) => e.stopPropagation()} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - if (highlightedIndex >= 0 && highlightedIndex < displayOptions.length) { - handleSelect(displayOptions[highlightedIndex]); - } else if (allowCustomValue && searchQuery.trim()) { - handleCustomValueSubmit(); - } - } else if (e.key === 'Escape') { - e.preventDefault(); - setIsOpen(false); - setSearchQuery(''); - } else if (e.key === 'ArrowDown') { - e.preventDefault(); - isKeyboardNavigation.current = true; - setHighlightedIndex((previous) => moveHighlight(previous, 1)); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - isKeyboardNavigation.current = true; - setHighlightedIndex((previous) => moveHighlight( - previous < 0 ? displayOptions.length : previous, - -1, - )); - } - }} - data-bf-component="select" - data-bf-part="searchInput" - /> - {searchQuery && ( - - )} -
- )} - - {multiple && showSelectAll && filteredOptions.length > 0 && ( -
!opt.disabled).every(opt => isSelected(opt.value))} - > - !opt.disabled).every(opt => isSelected(opt.value)) - ? 'select__checkbox--checked' : '' - }`}> - {filteredOptions.filter(opt => !opt.disabled).every(opt => isSelected(opt.value)) && '✓'} - - {t('select.selectAll')} -
- )} - -
- {filteredOptions.length === 0 ? ( - loading ? ( -
-
- ) : allowCustomValue && searchQuery.trim() ? ( -
handleCustomValueSubmit()} - > - "{searchQuery.trim()}" - {resolvedCustomValueHint} -
- ) : ( -
{resolvedEmptyText}
- ) - ) : groupedOptions.hasGroups ? ( - (() => { - let globalIndex = 0; - return ( - <> - {groupedOptions.ungrouped.map((option) => - renderOptionItem(option, globalIndex++) - )} - {Object.entries(groupedOptions.groups).map(([groupName, groupOptions]) => ( -
-
{groupName}
- {groupOptions.map((option) => - renderOptionItem(option, globalIndex++) - )} -
- ))} - - ); - })() - ) : ( - <> - {filteredOptions.map((option, index) => renderOptionItem(option, index))} - {allowCustomValue && searchQuery.trim() && - !filteredOptions.some(opt => ( - opt.label.toLowerCase() === searchQuery.trim().toLowerCase() || - String(opt.value).toLowerCase() === searchQuery.trim().toLowerCase() - )) && ( -
handleCustomValueSubmit()} - > - "{searchQuery.trim()}" - {resolvedCustomValueHint} -
- )} - - )} -
-
- )} - + {isOpen && (dropdownMode === 'overlay' + ? createPortal(dropdownNode, getAppearanceOverlayHost()) + : dropdownNode)} + {error && errorMessage && (
{errorMessage}
)} diff --git a/src/web-ui/src/features/dispatch/DispatchTargetPicker.scss b/src/web-ui/src/features/dispatch/DispatchTargetPicker.scss index b9137798d5..23624cabf3 100644 --- a/src/web-ui/src/features/dispatch/DispatchTargetPicker.scss +++ b/src/web-ui/src/features/dispatch/DispatchTargetPicker.scss @@ -64,14 +64,12 @@ } &__menu { - position: absolute; - right: 0; - bottom: calc(100% + 7px); - z-index: 10; + position: fixed; + z-index: $z-popover; box-sizing: border-box; display: flex; - width: min(300px, calc(100vw - 24px)); - max-height: min(410px, calc(100vh - 24px)); + width: min(300px, calc(100vw - 16px)); + max-height: min(410px, calc(100vh - 16px)); flex-direction: column; gap: 4px; padding: 6px; @@ -83,6 +81,7 @@ box-shadow: var(--bf-appearance-token-shadow-lg); color: var(--bf-appearance-token-color-text-primary); font-family: var(--bf-appearance-token-font-family-sans); + font-size: var(--bf-appearance-token-flowchat-font-size-xxs); user-select: none; } diff --git a/src/web-ui/src/features/dispatch/DispatchTargetPicker.test.tsx b/src/web-ui/src/features/dispatch/DispatchTargetPicker.test.tsx new file mode 100644 index 0000000000..d34d8d2532 --- /dev/null +++ b/src/web-ui/src/features/dispatch/DispatchTargetPicker.test.tsx @@ -0,0 +1,108 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DispatchTargetPicker } from './DispatchTargetPicker'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@/component-library', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +vi.mock('@/infrastructure/i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@/infrastructure/account/useAccountLoginState', () => ({ + useAccountLoginState: () => ({ loggedIn: false }), +})); + +vi.mock('./useDispatchTargets', () => ({ + useDispatchTargets: () => ({ + targets: [], + loading: false, + error: false, + refresh: vi.fn(async () => undefined), + }), +})); + +vi.mock('@/features/ssh-remote/SSHConnectionDialog', () => ({ + SSHConnectionDialog: () => null, +})); + +vi.mock('./DispatchInstallDialog', () => ({ + DispatchInstallDialog: () => null, +})); + +const rect = ( + top: number, + left: number, + width: number, + height: number, +): DOMRect => ({ + top, + bottom: top + height, + left, + right: left + width, + width, + height, + x: left, + y: top, + toJSON() { return this; }, +} as DOMRect); + +describe('DispatchTargetPicker overlay', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 800 }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 }); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { + if (this.dataset.testid === 'chat-input-dispatch-trigger') { + return rect(500, 420, 120, 40); + } + if (this.classList.contains('dispatch-target-picker__menu')) { + return rect(0, 0, 300, 200); + } + return rect(0, 0, 0, 0); + }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + document.querySelector('[data-bf-overlay-host="true"]')?.remove(); + container.remove(); + vi.restoreAllMocks(); + }); + + it('anchors the portalled menu to the actual dispatch trigger', async () => { + await act(async () => { + root.render( + , + ); + }); + + const trigger = container.querySelector( + '[data-testid="chat-input-dispatch-trigger"]', + ); + await act(async () => trigger?.click()); + + const menu = document.querySelector('[data-testid="dispatch-target-menu"]'); + expect(menu?.parentElement?.getAttribute('data-bf-overlay-host')).toBe('true'); + expect(menu?.style.visibility).toBe('visible'); + expect(menu?.style.left).toBe('240px'); + expect(menu?.style.top).toBe('293px'); + }); +}); diff --git a/src/web-ui/src/features/dispatch/DispatchTargetPicker.tsx b/src/web-ui/src/features/dispatch/DispatchTargetPicker.tsx index 1360d87da4..443f107822 100644 --- a/src/web-ui/src/features/dispatch/DispatchTargetPicker.tsx +++ b/src/web-ui/src/features/dispatch/DispatchTargetPicker.tsx @@ -6,6 +6,7 @@ import React, { useRef, useState, } from 'react'; +import { createPortal } from 'react-dom'; import { Check, ChevronDown, @@ -19,7 +20,9 @@ import { import { Tooltip } from '@/component-library'; import { SSHConnectionDialog } from '@/features/ssh-remote/SSHConnectionDialog'; import { useAccountLoginState } from '@/infrastructure/account/useAccountLoginState'; +import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { useI18n } from '@/infrastructure/i18n'; +import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import { DispatchInstallDialog } from './DispatchInstallDialog'; import type { DispatchSelection, @@ -52,12 +55,23 @@ 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 [configureTarget, setConfigureTarget] = useState(null); const [sshDialogOpen, setSshDialogOpen] = useState(false); const [accountDialogOpen, setAccountDialogOpen] = useState(false); const { loggedIn } = useAccountLoginState(); const { targets, loading, error, refresh } = useDispatchTargets(open); + const menuLayout = useAnchoredPopoverPosition({ + open, + anchorRef: triggerRef, + popoverRef: menuRef, + preferredPlacement: 'top', + alignment: 'end', + gap: 7, + layoutRevision: `${targets.length}:${loading}:${error ?? ''}`, + }); const displayLabel = target.kind === 'local' ? t('chatInput.dispatch.local') @@ -69,7 +83,11 @@ export const DispatchTargetPicker: React.FC = ({ useEffect(() => { if (!open) return; const handlePointerDown = (event: PointerEvent) => { - if (!rootRef.current?.contains(event.target as Node)) { + const targetNode = event.target as Node; + if ( + !rootRef.current?.contains(targetNode) + && !menuRef.current?.contains(targetNode) + ) { setOpen(false); } }; @@ -101,9 +119,16 @@ export const DispatchTargetPicker: React.FC = ({ const menu = open ? (
= ({ > - {menu} + {menu && createPortal(menu, getAppearanceOverlayHost())}
= ({ const richTextInputRef = useRef(null); const containerRef = useRef(null); + const mentionAnchorRef = useRef(null); const agentBoostRef = useRef(null); + const boostTriggerRef = useRef(null); + const boostMenuRef = useRef(null); + const slashCommandPickerRef = useRef(null); + const boostMenuLayout = useAnchoredPopoverPosition({ + open: modeState.dropdownOpen, + anchorRef: boostTriggerRef, + popoverRef: boostMenuRef, + preferredPlacement: 'top', + alignment: 'start', + gap: 6, + }); const isImeComposingRef = useRef(false); // Ref so the queuedInput sync effect can read the latest value without it being a dep const inputValueRef = useRef(''); @@ -1488,6 +1503,15 @@ export const ChatInput: React.FC = ({ query: '', selectedIndex: 0, }); + const slashCommandPickerLayout = useAnchoredPopoverPosition({ + open: slashCommandState.isActive, + anchorRef: mentionAnchorRef, + popoverRef: slashCommandPickerRef, + preferredPlacement: 'top', + alignment: 'start', + gap: 6, + layoutRevision: `${slashCommandState.kind}:${slashCommandState.query}`, + }); const slashPickerWasActiveRef = useRef(false); useEffect(() => { @@ -2473,9 +2497,9 @@ export const ChatInput: React.FC = ({ React.useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (agentBoostRef.current && !agentBoostRef.current.contains(event.target as Node)) { - dispatchMode({ type: 'CLOSE_DROPDOWN' }); - } + const target = event.target as Node; + if (agentBoostRef.current?.contains(target) || boostMenuRef.current?.contains(target)) return; + dispatchMode({ type: 'CLOSE_DROPDOWN' }); }; if (modeState.dropdownOpen) { @@ -5142,7 +5166,7 @@ export const ChatInput: React.FC = ({
)} -
+
{imageContexts.length > 0 && (
= ({ ? undefined : effectiveTargetSession?.workspaceId || workspace?.id} excludeSessionId={effectiveTargetSessionId || undefined} + anchorRef={mentionAnchorRef} onSelect={(context: FileContext | DirectoryContext | SessionReferenceContext) => { addContext(context); @@ -5235,11 +5260,24 @@ export const ChatInput: React.FC = ({ }} /> - {slashCommandState.isActive && (() => { + {slashCommandState.isActive && createPortal((() => { if (slashCommandState.kind === 'actions') { const actions = getFilteredActions(); return ( -
+
{t('chatInput.quickAction')} {t('chatInput.selectHint')} @@ -5276,7 +5314,20 @@ export const ChatInput: React.FC = ({ const firstModeIndex = items.findIndex(item => item.kind === 'mode'); const firstSkillIndex = items.findIndex(item => item.kind === 'skill'); return ( -
+
{t('chatInput.commands')} {t('chatInput.selectHint')} @@ -5374,7 +5425,20 @@ export const ChatInput: React.FC = ({ if (slashCommandState.kind === 'skills') { const items = getActiveSlashPickerItems(); return ( -
+
{t('chatInput.boostSkills')} {t('chatInput.selectHint')} @@ -5450,7 +5514,20 @@ export const ChatInput: React.FC = ({ const filteredModes = getFilteredSelectableModes(); return ( -
+
{t('chatInput.addModeMenuTitle')} {t('chatInput.selectHint')} @@ -5484,14 +5561,14 @@ export const ChatInput: React.FC = ({
); - })()} + })(), getAppearanceOverlayHost())}
{!isAcpTargetSession && ( - + = ({
)} - {modeState.dropdownOpen && ( -
+ {modeState.dropdownOpen && createPortal( +
{canSwitchModes && ( <>
@@ -5815,7 +5904,8 @@ export const ChatInput: React.FC = ({ {t('chatInput.boostNewSession')}
-
+
, + getAppearanceOverlayHost(), )}
diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss index 590783208c..ed218e7945 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss @@ -210,18 +210,20 @@ } &__permission-menu { - position: absolute; - right: 0; - bottom: calc(100% + 7px); - z-index: 10; + position: fixed; + z-index: $z-popover; box-sizing: border-box; - width: min(286px, calc(100vw - 24px)); + width: min(286px, calc(100vw - 16px)); + max-height: calc(100vh - 16px); + overflow-y: auto; padding: 6px; border: 1px solid var(--bf-appearance-token-border-subtle); border-radius: $size-radius-base; background: var(--bf-appearance-token-color-bg-elevated); box-shadow: var(--bf-appearance-token-shadow-lg); color: var(--bf-appearance-token-color-text-primary); + font-family: var(--bf-appearance-token-font-family-sans); + font-size: var(--bf-appearance-token-flowchat-font-size-xxs); user-select: none; } diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx index 1bcdda3f77..c055cc6137 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx @@ -49,6 +49,7 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { let root: Root; beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -65,6 +66,7 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { root.unmount(); }); container.remove(); + document.querySelector('[data-bf-overlay-host="true"]')?.remove(); vi.clearAllMocks(); }); @@ -127,26 +129,30 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { await act(async () => { trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); - expect(container.querySelector('[data-testid="chat-input-permission-menu"]')).not.toBeNull(); + const permissionMenu = document.querySelector( + '[data-testid="chat-input-permission-menu"]', + ); + expect(permissionMenu).not.toBeNull(); + expect(permissionMenu?.style.visibility).toBe('visible'); await act(async () => { - container + document .querySelector('[data-testid="chat-input-permission-option-auto"]') ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(onChange).toHaveBeenCalledWith('auto'); - expect(container.querySelector('[data-testid="chat-input-permission-menu"]')).toBeNull(); + expect(document.querySelector('[data-testid="chat-input-permission-menu"]')).toBeNull(); await act(async () => { trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); await act(async () => { - container + document .querySelector('[data-testid="chat-input-permission-hide-control"]') ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(onHide).toHaveBeenCalledOnce(); - expect(container.querySelector('[data-testid="chat-input-permission-menu"]')).toBeNull(); + expect(document.querySelector('[data-testid="chat-input-permission-menu"]')).toBeNull(); }); it('shows ACP ownership without exposing native permission choices', async () => { @@ -190,13 +196,13 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { await act(async () => { trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); - expect(container.textContent).toContain('This dispatched session'); - expect(container.querySelector( + expect(document.body.textContent).toContain('This dispatched session'); + expect(document.querySelector( '[data-testid="chat-input-permission-option-full_access"]', )).toBeNull(); await act(async () => { - container.querySelector( + document.querySelector( '[data-testid="chat-input-permission-option-auto"]', )?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx index 752a4bc2af..3dc602877a 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx @@ -3,6 +3,7 @@ */ import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { Activity, @@ -21,7 +22,9 @@ import type { ThreadGoalSnapshot } from '../services/goalService'; import { Tooltip, IconButton } from '@/component-library'; import { useGitState } from '@/tools/git/hooks/useGitState'; import type { SessionExecutionTarget } from '@/infrastructure/api/service-api/WorktreeAPI'; +import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { useI18n } from '@/infrastructure/i18n'; +import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import { DispatchResultDialog } from '@/features/dispatch/DispatchResultDialog'; import { DispatchTargetPicker } from '@/features/dispatch/DispatchTargetPicker'; import type { DispatchSelection, DispatchTarget } from '@/features/dispatch/types'; @@ -108,8 +111,19 @@ export const ChatInputWorkspaceStrip: React.FC = ( const { t: tWorktrees } = useI18n('worktrees'); const { t: tCommon } = useI18n('common'); const permissionRootRef = useRef(null); + const permissionTriggerRef = useRef(null); + const permissionMenuRef = useRef(null); const [permissionMenuOpen, setPermissionMenuOpen] = useState(false); const [resultDialogOpen, setResultDialogOpen] = useState(false); + const permissionMenuLayout = useAnchoredPopoverPosition({ + open: permissionMenuOpen, + anchorRef: permissionTriggerRef, + popoverRef: permissionMenuRef, + preferredPlacement: 'top', + alignment: 'end', + gap: 7, + layoutRevision: `${permissionControl?.options?.length ?? 0}:${Boolean(permissionControl?.onHide)}`, + }); const trimmedPath = repositoryPath.trim(); const label = workspaceLabel.trim(); @@ -177,7 +191,11 @@ export const ChatInputWorkspaceStrip: React.FC = ( if (!permissionMenuOpen) return; const handlePointerDown = (event: PointerEvent) => { - if (!permissionRootRef.current?.contains(event.target as Node)) { + const target = event.target as Node; + if ( + !permissionRootRef.current?.contains(target) + && !permissionMenuRef.current?.contains(target) + ) { setPermissionMenuOpen(false); } }; @@ -388,6 +406,7 @@ export const ChatInputWorkspaceStrip: React.FC = ( > - {permissionMenuOpen && permissionMode !== 'acp' ? ( + {permissionMenuOpen && permissionMode !== 'acp' ? createPortal(
= ( ) : null} -
+
, + getAppearanceOverlayHost(), ) : null}
) : null} diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStripLayout.test.ts b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStripLayout.test.ts index 554a8556cf..3654f48f07 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStripLayout.test.ts +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStripLayout.test.ts @@ -39,7 +39,7 @@ describe('ChatInputWorkspaceStrip layout styles', () => { expect(stylesheet).toContain('&--ask {'); expect(stylesheet).toContain('border-color: var(--bf-appearance-token-color-success-border);'); expect(stylesheet).toContain('background: var(--bf-appearance-token-color-success-bg);'); - expect(stylesheet).toContain('width: min(286px, calc(100vw - 24px));'); + expect(stylesheet).toContain('width: min(286px, calc(100vw - 16px));'); expect(stylesheet).toContain('@media (max-width: 560px)'); expect(stylesheet).toContain('&__permission-label'); expect(stylesheet).toContain('display: none;'); diff --git a/src/web-ui/src/flow_chat/components/FileMentionPicker.scss b/src/web-ui/src/flow_chat/components/FileMentionPicker.scss index cd6a436ad1..60306347de 100644 --- a/src/web-ui/src/flow_chat/components/FileMentionPicker.scss +++ b/src/web-ui/src/flow_chat/components/FileMentionPicker.scss @@ -30,6 +30,17 @@ flex-direction: column; backdrop-filter: blur(20px) saturate(1.2); -webkit-backdrop-filter: blur(20px) saturate(1.2); + + &--overlay { + position: fixed; + z-index: $z-popover; + width: min(400px, calc(100vw - 16px)); + min-width: min(260px, calc(100vw - 16px)); + max-width: calc(100vw - 16px); + max-height: calc(100vh - 16px); + color: var(--bf-appearance-token-color-text-primary); + font-family: var(--bf-appearance-token-font-family-sans); + } // Entry animation animation: mentionPickerSlideUp 0.18s cubic-bezier(0.34, 1.56, 0.64, 1); diff --git a/src/web-ui/src/flow_chat/components/FileMentionPicker.tsx b/src/web-ui/src/flow_chat/components/FileMentionPicker.tsx index d9aa80497c..4b612fc8c9 100644 --- a/src/web-ui/src/flow_chat/components/FileMentionPicker.tsx +++ b/src/web-ui/src/flow_chat/components/FileMentionPicker.tsx @@ -4,6 +4,7 @@ */ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { File, @@ -31,6 +32,8 @@ import type { } from '@/shared/types/context'; import { Tooltip } from '@/component-library'; import { createLogger } from '@/shared/utils/logger'; +import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; +import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import { workspaceReferenceItems, type FileItem, @@ -50,6 +53,8 @@ export interface FileMentionPickerProps { excludeSessionId?: string; onSelect: (context: FileContext | DirectoryContext | SessionReferenceContext) => void; onClose: () => void; + /** Anchor used by the default portalled overlay mode. */ + anchorRef?: React.RefObject; position?: { top: number; left: number }; onNavigate?: (direction: 'up' | 'down' | 'enter' | 'escape') => void; } @@ -66,6 +71,7 @@ export const FileMentionPicker: React.FC = ({ excludeSessionId, onSelect, onClose, + anchorRef, position, }) => { const { t } = useTranslation('flow-chat'); @@ -79,6 +85,7 @@ export const FileMentionPicker: React.FC = ({ const [currentPath, setCurrentPath] = useState(''); const [pathHistory, setPathHistory] = useState([]); const containerRef = useRef(null); + const fallbackAnchorRef = useRef(null); const fileAbortControllerRef = useRef(null); const fileSearchDebounceTimerRef = useRef(null); const sessionSearchDebounceTimerRef = useRef(null); @@ -337,6 +344,16 @@ export const FileMentionPicker: React.FC = ({ const currentDirName = currentPath ? currentPath.replace(/\\/g, '/').split('/').pop() || '' : workspacePath?.replace(/\\/g, '/').split('/').pop() || t('fileMention.rootDirectory'); + const isOverlay = Boolean(anchorRef) && !position; + const overlayLayout = useAnchoredPopoverPosition({ + open: isOpen && isOverlay, + anchorRef: anchorRef ?? fallbackAnchorRef, + popoverRef: containerRef, + preferredPlacement: 'top', + alignment: 'start', + gap: 8, + layoutRevision: `${displayItems.length}:${currentPath}:${isSearchMode}`, + }); useEffect(() => () => { if (fileSearchDebounceTimerRef.current !== null) window.clearTimeout(fileSearchDebounceTimerRef.current); @@ -459,11 +476,28 @@ export const FileMentionPicker: React.FC = ({ }, [displayItems.length, selectedIndex]); if (!isOpen) return null; - const style: React.CSSProperties = position ? { position: 'absolute', top: position.top, left: position.left } : {}; + const style: React.CSSProperties = position + ? { position: 'absolute', top: position.top, left: position.left } + : isOverlay + ? { + top: `${overlayLayout?.top ?? 0}px`, + left: `${overlayLayout?.left ?? 0}px`, + visibility: overlayLayout ? 'visible' : 'hidden', + } + : {}; const isLoading = isFileLoading || isSessionLoading; - return ( -
event.preventDefault()}> + const picker = ( +
event.preventDefault()} + >
{!isSearchMode && pathHistory.length > 0 && ( @@ -524,6 +558,8 @@ export const FileMentionPicker: React.FC = ({
); + + return isOverlay ? createPortal(picker, getAppearanceOverlayHost()) : picker; }; export default FileMentionPicker; diff --git a/src/web-ui/src/flow_chat/components/FileMentionPickerOverlay.test.tsx b/src/web-ui/src/flow_chat/components/FileMentionPickerOverlay.test.tsx new file mode 100644 index 0000000000..9458b138f5 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/FileMentionPickerOverlay.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment jsdom + +import React, { act, useRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FileMentionPicker } from './FileMentionPicker'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('@/component-library', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +vi.mock('@/infrastructure/api', () => ({ + sessionAPI: { + searchReferenceableSessions: vi.fn().mockResolvedValue([]), + }, + workspaceAPI: { + getDirectoryChildren: vi.fn().mockResolvedValue([]), + searchFilenamesOnly: vi.fn().mockResolvedValue([]), + }, +})); + +vi.mock('@/infrastructure/api/service-api/ExternalSourcesAPI', () => ({ + externalSourcesAPI: { + getWorkspaceReferences: vi.fn().mockResolvedValue({ references: [] }), + }, +})); + +const Harness: React.FC = () => { + const anchorRef = useRef(null); + return ( +
+ + +
+ ); +}; + +describe('FileMentionPicker overlay', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + document.querySelector('[data-bf-overlay-host="true"]')?.remove(); + container.remove(); + vi.clearAllMocks(); + }); + + it('renders above clipped composers through the appearance overlay host', async () => { + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + const picker = document.querySelector('.file-mention-picker--overlay'); + expect(picker?.parentElement?.getAttribute('data-bf-overlay-host')).toBe('true'); + expect(picker?.style.visibility).toBe('visible'); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.scss b/src/web-ui/src/flow_chat/components/ModelSelector.scss index 5286a09bcb..025af00a61 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.scss +++ b/src/web-ui/src/flow_chat/components/ModelSelector.scss @@ -155,11 +155,12 @@ // ==================== Dropdown menu ==================== &__dropdown { - position: absolute; - bottom: calc(100% + 6px); - left: 0; + position: fixed; min-width: clamp(0px, 220px, calc(100vw - 16px)); max-width: clamp(0px, 280px, calc(100vw - 16px)); + color: var(--bf-appearance-token-color-text-primary); + font-family: var(--bf-appearance-token-font-family-sans); + font-size: var(--bf-appearance-token-flowchat-font-size-sm); background: var(--bf-appearance-token-color-bg-elevated); border: 1px solid var(--bf-appearance-token-color-overlay-white-12); border-radius: 6px; diff --git a/src/web-ui/src/flow_chat/components/WelcomePanel.css b/src/web-ui/src/flow_chat/components/WelcomePanel.css index 95f8f3f113..fff2c1f892 100644 --- a/src/web-ui/src/flow_chat/components/WelcomePanel.css +++ b/src/web-ui/src/flow_chat/components/WelcomePanel.css @@ -234,17 +234,19 @@ /* ── Dropdown ── */ .welcome-panel__dropdown { - position: absolute; - top: calc(100% + 4px); - left: 0; - z-index: 100; + position: fixed; + z-index: 360; min-width: 210px; - max-width: 300px; + max-width: min(300px, calc(100vw - 16px)); + max-height: calc(100vh - 16px); background: var(--bf-appearance-token-color-bg-elevated); border: 1px solid var(--bf-appearance-token-border-base); border-radius: calc(var(--bf-appearance-token-flowchat-card-radius) + 2px); box-shadow: 0 8px 24px var(--bf-appearance-token-color-overlay-black-15); - overflow: hidden; + overflow-y: auto; + color: var(--bf-appearance-token-color-text-primary); + font-family: var(--bf-appearance-token-font-family-sans); + font-size: var(--bf-appearance-token-flowchat-font-size-sm); transform-origin: left top; animation: wp-dropdownIn 180ms cubic-bezier(0.23, 1, 0.32, 1); } diff --git a/src/web-ui/src/flow_chat/components/WelcomePanel.test.tsx b/src/web-ui/src/flow_chat/components/WelcomePanel.test.tsx index 81ac032c60..4af7015896 100644 --- a/src/web-ui/src/flow_chat/components/WelcomePanel.test.tsx +++ b/src/web-ui/src/flow_chat/components/WelcomePanel.test.tsx @@ -84,6 +84,7 @@ describe('WelcomePanel Git summary loading', () => { act(() => { root.unmount(); }); + document.querySelector('[data-bf-overlay-host="true"]')?.remove(); container.remove(); }); @@ -120,4 +121,18 @@ describe('WelcomePanel Git summary loading', () => { expect(container.querySelector('[data-bf-part="workspaceAction"]')).not.toBeNull(); expect(container.querySelector('[data-bf-part="gitAction"]')).not.toBeNull(); }); + + it('portals the workspace menu outside the scrollable welcome panel', async () => { + gitApiMock.isGitRepository.mockResolvedValue(false); + await act(async () => { + root.render(); + }); + + const trigger = container.querySelector('[data-bf-part="workspaceAction"]'); + await act(async () => trigger?.click()); + + const menu = document.querySelector('[data-bf-part="workspaceMenu"]'); + expect(menu?.parentElement?.getAttribute('data-bf-overlay-host')).toBe('true'); + expect(menu?.style.visibility).toBe('visible'); + }); }); diff --git a/src/web-ui/src/flow_chat/components/WelcomePanel.tsx b/src/web-ui/src/flow_chat/components/WelcomePanel.tsx index d55ba8d5fe..ec53a6a140 100644 --- a/src/web-ui/src/flow_chat/components/WelcomePanel.tsx +++ b/src/web-ui/src/flow_chat/components/WelcomePanel.tsx @@ -4,6 +4,7 @@ */ import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'; +import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { FolderOpen, FolderPlus, ChevronDown, Check, GitBranch } from 'lucide-react'; import { gitAPI } from '../../infrastructure/api'; @@ -14,6 +15,8 @@ import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext' import type { WorkspaceInfo } from '@/shared/types'; import CoworkExampleCards from './CoworkExampleCards'; import { useAgentIdentityDocument } from '@/app/scenes/my-agent/useAgentIdentityDocument'; +import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; +import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import './WelcomePanel.css'; const log = createLogger('WelcomePanel'); @@ -38,6 +41,7 @@ export const WelcomePanel: React.FC = ({ const [isSelectingWorkspace, setIsSelectingWorkspace] = useState(false); const workspaceDropdownRef = useRef(null); const workspaceTriggerRef = useRef(null); + const workspaceMenuRef = useRef(null); const { switchLeftPanelTab } = useApp(); const { @@ -72,6 +76,15 @@ export const WelcomePanel: React.FC = ({ () => openedWorkspacesList.filter(ws => ws.id !== currentWorkspace?.id), [openedWorkspacesList, currentWorkspace?.id], ); + const workspaceMenuLayout = useAnchoredPopoverPosition({ + open: workspaceDropdownOpen, + anchorRef: workspaceTriggerRef, + popoverRef: workspaceMenuRef, + preferredPlacement: 'bottom', + alignment: 'start', + gap: 4, + layoutRevision: otherWorkspaces.length, + }); const handleGitClick = useCallback(() => { switchLeftPanelTab('git'); @@ -151,7 +164,12 @@ export const WelcomePanel: React.FC = ({ useEffect(() => { if (!workspaceDropdownOpen) return; const handlePointerDown = (e: MouseEvent) => { - if (workspaceDropdownRef.current && !workspaceDropdownRef.current.contains(e.target as Node)) { + const target = e.target as Node; + if ( + workspaceDropdownRef.current && + !workspaceDropdownRef.current.contains(target) && + !workspaceMenuRef.current?.contains(target) + ) { setWorkspaceDropdownOpen(false); } }; @@ -272,8 +290,19 @@ export const WelcomePanel: React.FC = ({ className={`welcome-panel__inline-chevron${workspaceDropdownOpen ? ' welcome-panel__inline-chevron--open' : ''}`} /> - {workspaceDropdownOpen && ( -
+ {workspaceDropdownOpen && createPortal( +
+
, + getAppearanceOverlayHost(), )} {!isCoworkSession && gitState && ( 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 577a42c1fb..6befe113e7 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss @@ -225,11 +225,9 @@ } &__background-command-panel { - position: absolute; - top: calc(100% + 8px); - right: 0; - width: min(340px, calc(100vw - 32px)); - max-height: min(360px, calc(100vh - 96px)); + position: fixed; + width: min(340px, calc(100vw - 16px)); + max-height: min(360px, calc(100vh - 16px)); display: flex; flex-direction: column; overflow: hidden; @@ -239,7 +237,9 @@ box-shadow: var(--bf-appearance-token-shadow-lg); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); - z-index: 30; + z-index: $z-popover; + color: var(--bf-appearance-token-color-text-primary); + font-family: var(--bf-appearance-token-font-family-sans); } &__background-command-panel-header { @@ -377,6 +377,10 @@ top: auto; right: auto; z-index: 10000; + max-height: calc(100vh - 16px); + overflow-y: auto; + color: var(--bf-appearance-token-color-text-primary); + font-family: var(--bf-appearance-token-font-family-sans); } } 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 af8f9ceac6..2529071f8d 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 @@ -25,21 +25,21 @@ vi.mock('@/component-library', async () => { Tooltip: ({ children }: { children: React.ReactNode }) => ( {children} ), - IconButton: ({ + IconButton: ReactModule.forwardRef & { + size?: string; + tooltip?: string; + variant?: string; + }>(({ children, size, tooltip, variant, ...props - }: React.ButtonHTMLAttributes & { - size?: string; - tooltip?: string; - variant?: string; - }) => ( - - ), + )), Input: ReactModule.forwardRef>((props, ref) => ( )), @@ -93,6 +93,7 @@ describe('FlowChatHeader', () => { act(() => { root.unmount(); }); + document.querySelector('[data-bf-overlay-host="true"]')?.remove(); container.remove(); vi.restoreAllMocks(); }); @@ -171,8 +172,10 @@ describe('FlowChatHeader', () => { commandButton?.click(); }); - const panel = container.querySelector('.flowchat-header__background-command-panel'); + const panel = document.querySelector('.flowchat-header__background-command-panel'); expect(panel?.textContent).toContain('flowChatHeader.backgroundCommandEmpty'); + expect(panel?.parentElement?.getAttribute('data-bf-overlay-host')).toBe('true'); + expect(panel?.style.visibility).toBe('visible'); }); it('renders background command menus in a portal outside the scrollable panel', () => { @@ -204,7 +207,7 @@ describe('FlowChatHeader', () => { commandButton?.click(); }); - const panel = container.querySelector('.flowchat-header__background-command-panel'); + const panel = document.querySelector('.flowchat-header__background-command-panel'); const menuButton = panel?.querySelector( '.flowchat-header__background-command-panel-header-actions [aria-label="flowChatHeader.backgroundCommandActions"]', ); @@ -217,11 +220,12 @@ describe('FlowChatHeader', () => { const menu = document.querySelector('[data-testid="flowchat-header-background-menu"]'); expect(menu).not.toBeNull(); expect(panel?.contains(menu ?? null)).toBe(false); + expect(menu?.classList.contains('flowchat-header__background-command-menu--portal')).toBe(true); act(() => { menu?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); }); - expect(container.querySelector('.flowchat-header__background-command-panel')).not.toBeNull(); + expect(document.querySelector('.flowchat-header__background-command-panel')).not.toBeNull(); const stopButton = menu?.querySelector('[role="menuitem"]'); act(() => { 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 887d54b1a4..6553cf9563 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx @@ -14,6 +14,7 @@ import { SessionTreePopover, type SessionTreeSelection } from './SessionTreePopo import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance'; import { computeFixedPopoverPosition } from '@/shared/utils/fixedPopoverViewport'; +import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import { createReviewPlatformTab } from '@/shared/utils/tabUtils'; import './FlowChatHeader.scss'; @@ -109,6 +110,8 @@ export const FlowChatHeader: React.FC = ({ const headerRef = useRef(null); const leftActionsRef = useRef(null); const rightActionsRef = useRef(null); + const backgroundCommandRootRef = useRef(null); + const backgroundCommandTriggerRef = useRef(null); const backgroundCommandPanelRef = useRef(null); const backgroundCommandMenuAnchorRef = useRef(null); const backgroundCommandMenuRef = useRef(null); @@ -137,6 +140,15 @@ export const FlowChatHeader: React.FC = ({ const hasOpenBackgroundCommandMenu = isBackgroundCommandSectionMenuOpen || openBackgroundCommandMenuId !== null; + const backgroundCommandPanelLayout = useAnchoredPopoverPosition({ + open: isBackgroundCommandPanelOpen, + anchorRef: backgroundCommandTriggerRef, + popoverRef: backgroundCommandPanelRef, + preferredPlacement: 'bottom', + alignment: 'end', + gap: 8, + layoutRevision: backgroundCommandCount, + }); const updateBackgroundCommandMenuPosition = useCallback(() => { const anchor = backgroundCommandMenuAnchorRef.current; @@ -164,6 +176,7 @@ export const FlowChatHeader: React.FC = ({ const handlePointerDown = (event: MouseEvent) => { const target = event.target as Node; if ( + !backgroundCommandRootRef.current?.contains(target) && !backgroundCommandPanelRef.current?.contains(target) && !backgroundCommandMenuRef.current?.contains(target) ) { @@ -401,7 +414,7 @@ export const FlowChatHeader: React.FC = ({ {openBackgroundCommandMenuId === command.execSessionKey && backgroundCommandMenuPosition ? createPortal(
= ({ />
= ({ - {isBackgroundCommandPanelOpen && ( + {isBackgroundCommandPanelOpen && createPortal(
{backgroundCommandLabel} @@ -580,7 +601,7 @@ export const FlowChatHeader: React.FC = ({ {isBackgroundCommandSectionMenuOpen && backgroundCommandMenuPosition ? createPortal(
= ({
)}
-
+
, + getAppearanceOverlayHost(), )}
diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss index 74dcd97965..7e45f09a33 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss @@ -1,5 +1,7 @@ /* Model round item styles (BEM). */ +@use '../../../component-library/styles/tokens.scss' as *; + .model-round-item { /* * No bottom margin — the inter-round gap is covered by the same line-height rhythm @@ -316,10 +318,8 @@ } .model-round-item__copy-menu { - position: absolute; - right: 0; - bottom: calc(100% + 4px); - z-index: 20; + position: fixed; + z-index: $z-popover; display: flex; flex-direction: column; min-width: 140px; @@ -328,6 +328,8 @@ border: 1px solid var(--bf-appearance-token-border-subtle); border-radius: 8px; background: var(--bf-appearance-token-color-bg-elevated); + color: var(--bf-appearance-token-color-text-primary); + font-family: var(--bf-appearance-token-font-family-sans); box-shadow: 0 12px 28px var(--bf-appearance-token-color-overlay-black-30), 0 2px 8px var(--bf-appearance-token-color-overlay-black-12); diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx index 84d2442797..ecc6ae87fd 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx @@ -8,6 +8,7 @@ */ import React, { useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { Copy, Check, CircleAlert } from 'lucide-react'; import type { ModelRound, ModelRoundAttempt, ModelRoundAttemptDiagnostic, FlowItem, FlowTextItem, FlowToolItem, FlowThinkingItem, TokenUsage, ToolRejectOptions } from '../../types/flow-chat'; @@ -41,6 +42,8 @@ import { buildModelRoundUsageMeta } from '../../utils/tokenUsageDisplay'; import { buildDialogTurnCopyText } from '../../utils/dialogTurnCopy'; import type { TranscriptExportScope } from '../../utils/dialogTranscriptExport'; import { buildTranscriptExportLabels } from '../../utils/transcriptExportLabels'; +import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; +import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import './ModelRoundItem.scss'; import './SubagentItems.scss'; @@ -371,6 +374,14 @@ export const ModelRoundItem = React.memo( const [isCopyMenuOpen, setIsCopyMenuOpen] = useState(false); const copyButtonRef = useRef(null); const copyMenuRef = useRef(null); + const copyMenuLayout = useAnchoredPopoverPosition({ + open: isCopyMenuOpen, + anchorRef: copyButtonRef, + popoverRef: copyMenuRef, + preferredPlacement: 'top', + alignment: 'end', + gap: 4, + }); const renderTraceEnabled = isStartupRenderTraceEnabled(); const renderTraceStartedAtMs = renderTraceEnabled ? performance.now() : null; @@ -773,12 +784,18 @@ export const ModelRoundItem = React.memo( - {isCopyMenuOpen && ( + {isCopyMenuOpen && createPortal(
-
+
, + getAppearanceOverlayHost(), )}
} diff --git a/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.scss b/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.scss index 8019fd71c5..81ae155286 100644 --- a/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.scss +++ b/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.scss @@ -204,12 +204,12 @@ } &__review-menu-popover { - position: absolute; - top: calc(100% + 4px); - left: 0; - right: auto; - z-index: 110; + position: fixed; + z-index: $z-popover; min-width: 150px; + max-width: calc(100vw - 16px); + max-height: calc(100vh - 16px); + overflow-y: auto; padding: 4px; border: 1px solid var(--bf-appearance-token-border-subtle); border-radius: $size-radius-base; @@ -217,6 +217,8 @@ box-shadow: 0 4px 16px var(--bf-appearance-token-color-overlay-black-30); backdrop-filter: blur(20px) saturate(1.3); -webkit-backdrop-filter: blur(20px) saturate(1.3); + color: var(--bf-appearance-token-color-text-primary); + font-family: var(--bf-appearance-token-font-family-sans); animation: session-files-badge-popover-in 0.2s $easing-standard; } @@ -307,18 +309,21 @@ // ==================== Popover ==================== &__popover { - position: absolute; - top: calc(100% + 4px); - left: -4px; - z-index: 100; - min-width: 260px; - max-width: 380px; + position: fixed; + z-index: $z-popover; + width: min(380px, calc(100vw - 16px)); + min-width: min(260px, calc(100vw - 16px)); + max-width: calc(100vw - 16px); + max-height: calc(100vh - 16px); + overflow: hidden; background: color-mix(in srgb, var(--bf-appearance-token-color-bg-elevated) 98%, transparent); backdrop-filter: blur(20px) saturate(1.3); -webkit-backdrop-filter: blur(20px) saturate(1.3); border: 1px solid var(--bf-appearance-token-border-subtle); border-radius: $size-radius-base; box-shadow: 0 4px 16px var(--bf-appearance-token-color-overlay-black-30); + color: var(--bf-appearance-token-color-text-primary); + font-family: var(--bf-appearance-token-font-family-sans); animation: session-files-badge-popover-in 0.2s $easing-standard; @keyframes session-files-badge-popover-in { @@ -364,7 +369,7 @@ // ==================== File list ==================== &__list { - max-height: 280px; + max-height: min(280px, calc(100vh - 64px)); overflow-y: auto; padding: 4px 6px 6px; diff --git a/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.test.tsx b/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.test.tsx index 9685f62d46..4004e23cc6 100644 --- a/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.test.tsx @@ -154,6 +154,7 @@ describe('SessionFilesBadge', () => { vi.stubGlobal('window', dom.window); vi.stubGlobal('document', dom.window.document); vi.stubGlobal('HTMLElement', dom.window.HTMLElement); + vi.stubGlobal('HTMLDivElement', dom.window.HTMLDivElement); vi.stubGlobal('CustomEvent', dom.window.CustomEvent); container = dom.window.document.getElementById('root') as HTMLDivElement; @@ -186,6 +187,7 @@ describe('SessionFilesBadge', () => { act(() => { root.unmount(); }); + dom.window.document.querySelector('[data-bf-overlay-host="true"]')?.remove(); vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -207,7 +209,10 @@ describe('SessionFilesBadge', () => { toggle?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); }); - expect(container.textContent).toContain('2 files'); + const filesPopover = dom.window.document.querySelector('.session-files-badge__popover'); + expect(filesPopover?.parentElement?.getAttribute('data-bf-overlay-host')).toBe('true'); + expect(filesPopover?.style.visibility).toBe('visible'); + expect(dom.window.document.body.textContent).toContain('2 files'); mocks.files = [ { filePath: 'src/current-session.ts', sessionId: 'session-1' }, @@ -222,8 +227,8 @@ describe('SessionFilesBadge', () => { await Promise.resolve(); }); - expect(container.textContent).toContain('1 files'); - expect(container.textContent).not.toContain('stale-session.ts'); + expect(dom.window.document.body.textContent).toContain('1 files'); + expect(dom.window.document.body.textContent).not.toContain('stale-session.ts'); }); it('presents one adaptive Review action instead of asking users to choose a depth', async () => { @@ -243,9 +248,12 @@ describe('SessionFilesBadge', () => { actionsButton?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); }); - expect(container.textContent).toContain('Review'); - expect(container.textContent).not.toContain('Review: Strict'); - expect(container.textContent).not.toContain('Deep review'); + const reviewPopover = dom.window.document.querySelector('.session-files-badge__review-menu-popover'); + expect(reviewPopover?.parentElement?.getAttribute('data-bf-overlay-host')).toBe('true'); + expect(reviewPopover?.style.visibility).toBe('visible'); + expect(dom.window.document.body.textContent).toContain('Review'); + expect(dom.window.document.body.textContent).not.toContain('Review: Strict'); + expect(dom.window.document.body.textContent).not.toContain('Deep review'); }); it('shows a localized error when Review cannot be prepared', async () => { @@ -264,7 +272,7 @@ describe('SessionFilesBadge', () => { await act(async () => { actionsButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); }); - const reviewButton = container.querySelector('[role="menuitem"]') as HTMLButtonElement; + const reviewButton = dom.window.document.querySelector('[role="menuitem"]') as HTMLButtonElement; await act(async () => { reviewButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); await Promise.resolve(); diff --git a/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.tsx b/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.tsx index 70087b2907..5a106a4715 100644 --- a/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.tsx +++ b/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.tsx @@ -4,6 +4,7 @@ */ import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; import { FileEdit, FilePlus, @@ -48,6 +49,8 @@ import { resolveQuickActionText } from '@/infrastructure/config/services/quickAc import { deriveDeepReviewSessionConcurrencyGuard } from '../../utils/deepReviewCapacityGuard'; import { scheduleAfterStartupSignal } from '@/shared/utils/startupTaskScheduling'; import { isTauriRuntime } from '@/infrastructure/runtime'; +import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; +import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import './SessionFilesBadge.scss'; const log = createLogger('SessionFilesBadge'); @@ -202,9 +205,29 @@ export const SessionFilesBadge: React.FC = ({ }); const badgeRef = useRef(null); - const popoverRef = useRef(null); - const reviewMenuRef = useRef(null); + const fileTriggerRef = useRef(null); + const filePopoverRef = useRef(null); + const reviewTriggerRef = useRef(null); + const reviewPopoverRef = useRef(null); const { confirmDeepReviewLaunch, deepReviewConsentDialog } = useDeepReviewConsent(); + const reviewPopoverLayout = useAnchoredPopoverPosition({ + open: isReviewMenuOpen && !isReviewLaunchOrActivityBlocking, + anchorRef: reviewTriggerRef, + popoverRef: reviewPopoverRef, + preferredPlacement: 'bottom', + alignment: 'end', + gap: 4, + layoutRevision: quickActions, + }); + const filePopoverLayout = useAnchoredPopoverPosition({ + open: isExpanded && fileStats.size > 0, + anchorRef: fileTriggerRef, + popoverRef: filePopoverRef, + preferredPlacement: 'bottom', + alignment: 'end', + gap: 4, + layoutRevision: fileStats, + }); const clearReviewReadyGlint = useCallback(() => { setShowReviewReadyGlint(false); @@ -334,8 +357,8 @@ export const SessionFilesBadge: React.FC = ({ const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node; const clickedBadge = !!badgeRef.current?.contains(target); - const clickedFilesPopover = !!popoverRef.current?.contains(target); - const clickedReviewMenu = !!reviewMenuRef.current?.contains(target); + const clickedFilesPopover = !!filePopoverRef.current?.contains(target); + const clickedReviewMenu = !!reviewPopoverRef.current?.contains(target); if (!clickedBadge && !clickedFilesPopover && !clickedReviewMenu) { setIsExpanded(false); setIsReviewMenuOpen(false); @@ -755,12 +778,12 @@ export const SessionFilesBadge: React.FC = ({ className={`session-files-badge ${isExpanded ? 'session-files-badge--expanded' : ''}`} >
- {isReviewMenuOpen && !isReviewLaunchOrActivityBlocking && ( -
+ {isReviewMenuOpen && !isReviewLaunchOrActivityBlocking && createPortal( +
{canLaunchReview && ); })} -
+
, + getAppearanceOverlayHost(), )}
{showFileStatsSummary ? ( ) : null} - {showFileStatsSummary && isExpanded && ( + {showFileStatsSummary && isExpanded && createPortal(
@@ -955,7 +998,8 @@ export const SessionFilesBadge: React.FC = ({
))}
-
+
, + getAppearanceOverlayHost(), )}
{deepReviewConsentDialog} diff --git a/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.scss b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.scss index 8968b1d94c..12f813c8e5 100644 --- a/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.scss +++ b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.scss @@ -43,11 +43,9 @@ } &__panel { - position: absolute; - top: calc(100% + 8px); - right: 0; - width: min(380px, calc(100vw - 32px)); - max-height: min(440px, calc(100vh - 96px)); + position: fixed; + width: min(380px, calc(100vw - 16px)); + max-height: min(440px, calc(100vh - 16px)); display: flex; flex-direction: column; overflow: hidden; @@ -57,7 +55,9 @@ box-shadow: var(--bf-appearance-token-shadow-lg); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); - z-index: 30; + z-index: $z-popover; + color: var(--bf-appearance-token-color-text-primary); + font-family: var(--bf-appearance-token-font-family-sans); } &__header { @@ -73,7 +73,7 @@ &__body { min-height: 42px; - max-height: min(390px, calc(100vh - 144px)); + max-height: min(390px, calc(100vh - 64px)); padding: $size-gap-1; overflow-y: auto; scrollbar-gutter: stable; diff --git a/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.test.tsx b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.test.tsx index 1faf8012e5..7c739510bf 100644 --- a/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.test.tsx @@ -30,13 +30,15 @@ vi.mock('@/component-library', async () => { return { DotMatrixLoader: () => , - IconButton: ({ + IconButton: ReactModule.forwardRef & { + tooltip?: string; + }>(({ children, tooltip, ...props - }: React.ButtonHTMLAttributes & { tooltip?: string }) => ( - - ), + }, ref) => ( + + )), }; }); @@ -78,7 +80,7 @@ describe('SessionTreePopover', () => { afterEach(() => { act(() => root.unmount()); container.remove(); - document.body.querySelector('[data-testid="flowchat-header-session-tree-menu"]')?.remove(); + document.querySelector('[data-bf-overlay-host="true"]')?.remove(); vi.restoreAllMocks(); }); @@ -102,11 +104,14 @@ describe('SessionTreePopover', () => { await Promise.resolve(); }); - const actionButton = container.querySelector( + const panel = document.querySelector('.session-tree-popover__panel'); + expect(panel?.parentElement?.getAttribute('data-bf-overlay-host')).toBe('true'); + expect(panel?.style.visibility).toBe('visible'); + const actionButton = panel?.querySelector( '[aria-label="flowChatHeader.agentTreeActions"]', ); expect(actionButton).not.toBeNull(); - const childNode = Array.from(container.querySelectorAll('[role="treeitem"]')) + const childNode = Array.from(panel?.querySelectorAll('[role="treeitem"]') ?? []) .find(node => node.textContent?.includes('Running child')); const status = childNode?.querySelector('.session-tree-popover__status'); expect(childNode).not.toBeUndefined(); diff --git a/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.tsx b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.tsx index 4b12749cdf..cdafafaf34 100644 --- a/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.tsx +++ b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.tsx @@ -13,6 +13,7 @@ import { DotMatrixLoader, IconButton } from '@/component-library'; import { sessionAPI, type SessionLineageSnapshot } from '@/infrastructure/api/service-api/SessionAPI'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance'; import { computeFixedPopoverPosition } from '@/shared/utils/fixedPopoverViewport'; +import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import { flowChatStore } from '../../store/FlowChatStore'; import { buildSessionLineageTree, @@ -74,6 +75,8 @@ export const SessionTreePopover: React.FC = ({ const [openActionSessionId, setOpenActionSessionId] = useState(null); const [cancellingSessionIds, setCancellingSessionIds] = useState>(new Set()); const containerRef = useRef(null); + const triggerRef = useRef(null); + const panelRef = useRef(null); const actionMenuAnchorRef = useRef(null); const actionMenuRef = useRef(null); const requestGenerationRef = useRef(0); @@ -148,6 +151,7 @@ export const SessionTreePopover: React.FC = ({ const handlePointerDown = (event: MouseEvent) => { if ( !containerRef.current?.contains(event.target as Node) && + !panelRef.current?.contains(event.target as Node) && !actionMenuRef.current?.contains(event.target as Node) ) { setIsOpen(false); @@ -208,6 +212,15 @@ export const SessionTreePopover: React.FC = ({ ); }, [liveRevision, sessionId, snapshot]); const descendantCount = countSessionLineageDescendants(tree); + const panelLayout = useAnchoredPopoverPosition({ + open: isOpen, + anchorRef: triggerRef, + popoverRef: panelRef, + preferredPlacement: 'bottom', + alignment: 'end', + gap: 8, + layoutRevision: `${descendantCount}:${isLoading}:${loadFailed}`, + }); useEffect(() => { if (!tree) return; @@ -430,6 +443,7 @@ export const SessionTreePopover: React.FC = ({ data-bf-part="sessionTree" > = ({ - {isOpen ? ( + {isOpen ? createPortal(
= ({
) : null}
-
+
, + getAppearanceOverlayHost(), ) : null}
); diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.tsx index de6691b14f..9dd8781c7e 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.tsx @@ -47,6 +47,7 @@ const RichUserMessageEditComposer: React.FC = excludeSessionId, }) => { const editorRef = useRef(null); + const mentionAnchorRef = useRef(null); const [contexts, setContexts] = useState(() => ( composerPresentationContexts(presentation) )); @@ -115,7 +116,7 @@ const RichUserMessageEditComposer: React.FC = return (
-
+
= workspacePath={workspacePath} workspaceId={workspaceId} excludeSessionId={excludeSessionId} + anchorRef={mentionAnchorRef} onSelect={handleSelectContext} onClose={() => editorRef.current?.closeMention?.()} /> diff --git a/src/web-ui/src/flow_chat/components/overlayClippingContract.test.ts b/src/web-ui/src/flow_chat/components/overlayClippingContract.test.ts new file mode 100644 index 0000000000..50f16c2021 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/overlayClippingContract.test.ts @@ -0,0 +1,44 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +function readSource(relativePath: string): string { + return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8') + .replace(/\r\n?/g, '\n'); +} + +const ANCHORED_OVERLAYS = [ + { name: 'shared Select', path: '../../component-library/components/Select/Select.tsx' }, + { name: 'chat input pickers', path: './ChatInput.tsx' }, + { name: 'file mention picker', path: './FileMentionPicker.tsx' }, + { name: 'permission menu', path: './ChatInputWorkspaceStrip.tsx' }, + { name: 'welcome workspace menu', path: './WelcomePanel.tsx' }, + { name: 'session menu', path: './session-menu/SessionMenu.tsx' }, + { name: 'toolbar overflow menu', path: './toolbar-mode/ToolbarMode.tsx' }, + { name: 'message copy menu', path: './modern/ModelRoundItem.tsx' }, + { name: 'session file menus', path: './modern/SessionFilesBadge.tsx' }, + { name: 'session tree panel', path: './modern/SessionTreePopover.tsx' }, + { name: 'background command panel', path: './modern/FlowChatHeader.tsx' }, + { name: 'tool timeout menu', path: '../tool-cards/ToolTimeoutIndicator.tsx' }, + { name: 'dispatch target menu', path: '../../features/dispatch/DispatchTargetPicker.tsx' }, + { name: 'profile avatar menu', path: '../../app/scenes/profile/views/AssistantAvatarPicker.tsx' }, + { name: 'shell creation menu', path: '../../app/scenes/shell/ShellNav.tsx' }, + { name: 'navigation footer menu', path: '../../app/components/NavPanel/components/PersistentFooterActions.tsx' }, +]; + +describe.each(ANCHORED_OVERLAYS)('$name clipping contract', ({ path }) => { + const source = readSource(path); + + it('escapes ancestor overflow and tracks its trigger in the viewport', () => { + expect(source).toContain('createPortal'); + expect(source).toContain('getAppearanceOverlayHost'); + expect(source).toContain('useAnchoredPopoverPosition'); + }); +}); + +describe('existing FlowChat background command menus', () => { + it('enables their fixed-position portal modifier at both render sites', () => { + const source = readSource('./modern/FlowChatHeader.tsx'); + expect(source.match(/flowchat-header__background-command-menu--portal/g)).toHaveLength(2); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/session-menu/SessionMenu.scss b/src/web-ui/src/flow_chat/components/session-menu/SessionMenu.scss index ed981ed6ea..6e4272c4fe 100644 --- a/src/web-ui/src/flow_chat/components/session-menu/SessionMenu.scss +++ b/src/web-ui/src/flow_chat/components/session-menu/SessionMenu.scss @@ -3,6 +3,8 @@ * Moved here from ToolbarMode.scss so both surfaces render the same menu. */ +@use '../../../component-library/styles/tokens' as tokens; + $session-menu-timing: cubic-bezier(0.4, 0, 0.2, 1); .bitfun-session-menu { @@ -47,13 +49,11 @@ $session-menu-timing: cubic-bezier(0.4, 0, 0.2, 1); } .bitfun-session-menu__dropdown { - position: absolute; - top: 100%; - left: 0; - margin-top: 4px; - width: max(100%, 220px); - max-width: min(360px, calc(100vw - 24px)); - max-height: min(360px, calc(100vh - 80px)); + --session-menu-entry-y: -4px; + position: fixed; + box-sizing: border-box; + width: min(220px, calc(100vw - 16px)); + max-height: min(360px, calc(100vh - 16px)); display: flex; flex-direction: column; overflow: hidden; @@ -61,9 +61,17 @@ $session-menu-timing: cubic-bezier(0.4, 0, 0.2, 1); border: 1px solid var(--bf-appearance-token-border-medium); border-radius: var(--bf-appearance-token-size-radius-base); box-shadow: var(--bf-appearance-token-shadow-xl); - z-index: 100; + z-index: tokens.$z-popover; animation: session-menu-appear 0.2s $session-menu-timing; transform-origin: top left; + font-family: var(--bf-appearance-token-font-family-sans); + font-size: var(--bf-appearance-token-flowchat-font-size-sm); + color: var(--bf-appearance-token-color-text-primary); + + &[data-bf-placement='top'] { + --session-menu-entry-y: 4px; + transform-origin: bottom left; + } .bitfun-session-menu__actions .bitfun-session-menu__item--new:first-of-type { border-radius: 7px 7px 0 0; @@ -179,7 +187,7 @@ $session-menu-timing: cubic-bezier(0.4, 0, 0.2, 1); @keyframes session-menu-appear { from { opacity: 0; - transform: translateY(-4px) scale(0.98); + transform: translateY(var(--session-menu-entry-y)) scale(0.98); } to { opacity: 1; diff --git a/src/web-ui/src/flow_chat/components/session-menu/SessionMenu.test.tsx b/src/web-ui/src/flow_chat/components/session-menu/SessionMenu.test.tsx new file mode 100644 index 0000000000..d21717cf6d --- /dev/null +++ b/src/web-ui/src/flow_chat/components/session-menu/SessionMenu.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SessionMenu } from './SessionMenu'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('@/component-library', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +vi.mock('./useFlowChatSessions', () => ({ + resolveDisplayTitle: (session: { title: string }) => session.title, + useFlowChatSessions: () => ({ + activeSessionId: 'session-1', + sessions: [ + { sessionId: 'session-1', title: 'Current session' }, + { sessionId: 'session-2', title: 'Other session' }, + ], + }), +})); + +vi.mock('../../services/sessionActivation', () => ({ + activateMainSession: vi.fn().mockResolvedValue(true), +})); + +describe('SessionMenu', () => { + let container: HTMLDivElement; + let root: Root; + let rectSpy: ReturnType; + let widthSpy: ReturnType; + let heightSpy: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1000 }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 }); + + rectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { + if (this instanceof HTMLElement && this.classList.contains('bitfun-session-menu__trigger')) { + return { + top: 760, + bottom: 784, + left: 900, + right: 924, + width: 24, + height: 24, + x: 900, + y: 760, + toJSON() { return this; }, + } as DOMRect; + } + return { + top: 0, + bottom: 0, + left: 0, + right: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON() { return this; }, + } as DOMRect; + }); + widthSpy = vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(240); + heightSpy = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(180); + }); + + afterEach(() => { + act(() => root.unmount()); + document.querySelector('[data-bf-overlay-host="true"]')?.remove(); + container.remove(); + rectSpy.mockRestore(); + widthSpy.mockRestore(); + heightSpy.mockRestore(); + vi.useRealTimers(); + }); + + it('escapes the floating-window clip and flips inside the viewport', async () => { + await act(async () => { + root.render(); + }); + + const trigger = container.querySelector('.bitfun-session-menu__trigger'); + await act(async () => { + trigger!.click(); + await Promise.resolve(); + }); + + const dropdown = document.querySelector('.bitfun-session-menu__dropdown'); + expect(dropdown?.parentElement?.getAttribute('data-bf-overlay-host')).toBe('true'); + expect(dropdown?.dataset.bfPlacement).toBe('top'); + expect(dropdown?.style.visibility).toBe('visible'); + expect(Number.parseFloat(dropdown?.style.left ?? '')).toBeLessThanOrEqual(752); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/session-menu/SessionMenu.tsx b/src/web-ui/src/flow_chat/components/session-menu/SessionMenu.tsx index 442731d56b..bc93a26490 100644 --- a/src/web-ui/src/flow_chat/components/session-menu/SessionMenu.tsx +++ b/src/web-ui/src/flow_chat/components/session-menu/SessionMenu.tsx @@ -9,9 +9,12 @@ */ import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { Plus } from 'lucide-react'; import { Tooltip } from '@/component-library'; +import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; +import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import { activateMainSession } from '../../services/sessionActivation'; import { useFlowChatSessions, resolveDisplayTitle } from './useFlowChatSessions'; import './SessionMenu.scss'; @@ -25,7 +28,17 @@ export const SessionMenu: React.FC = ({ onOpenChange }) => { const { t } = useTranslation('flow-chat'); const { activeSessionId, sessions } = useFlowChatSessions(); const [isMenuOpen, setIsMenuOpen] = useState(false); + const rootRef = useRef(null); + const triggerRef = useRef(null); const dropdownRef = useRef(null); + const dropdownLayout = useAnchoredPopoverPosition({ + open: isMenuOpen, + anchorRef: triggerRef, + popoverRef: dropdownRef, + preferredPlacement: 'bottom', + gap: 4, + layoutRevision: sessions.length, + }); const setOpen = useCallback((open: boolean) => { setIsMenuOpen(open); @@ -53,8 +66,8 @@ export const SessionMenu: React.FC = ({ onOpenChange }) => { const handleClickOutside = (e: MouseEvent) => { const target = e.target as HTMLElement | null; if (!target) return; + if (rootRef.current?.contains(target)) return; if (dropdownRef.current?.contains(target)) return; - if (target.closest?.('.bitfun-session-menu__trigger')) return; setOpen(false); }; @@ -77,6 +90,7 @@ export const SessionMenu: React.FC = ({ onOpenChange }) => { return (
= ({ onOpenChange }) => { > - {isMenuOpen && ( + {isMenuOpen && createPortal(
e.stopPropagation()} >
@@ -177,7 +198,8 @@ export const SessionMenu: React.FC = ({ onOpenChange }) => { ))}
-
+
, + getAppearanceOverlayHost(), )}
); diff --git a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.scss b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.scss index 4219bf6b5e..c9bf157517 100644 --- a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.scss +++ b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.scss @@ -3,6 +3,8 @@ * Compact floating bar with a two-row layout. */ +@use '../../../component-library/styles/tokens.scss' as *; + $transition-duration: 0.25s; $transition-timing: cubic-bezier(0.4, 0, 0.2, 1); @@ -366,16 +368,20 @@ $transition-timing: cubic-bezier(0.4, 0, 0.2, 1); } .bitfun-toolbar-mode__overflow-menu { - position: absolute; - top: calc(100% + 4px); - right: 0; + position: fixed; min-width: 180px; + max-width: calc(100vw - 16px); + max-height: calc(100vh - 16px); + overflow-y: auto; padding: 4px; background: var(--bf-appearance-token-color-bg-primary); border: 1px solid var(--bf-appearance-token-border-medium); border-radius: var(--bf-appearance-token-size-radius-base); box-shadow: var(--bf-appearance-token-shadow-xl); - z-index: 120; + z-index: $z-popover; + color: var(--bf-appearance-token-color-text-primary); + font-family: var(--bf-appearance-token-font-family-sans); + font-size: var(--bf-appearance-token-flowchat-font-size-sm); animation: dropdown-appear 0.2s $transition-timing; } diff --git a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx index 798d3faea9..8b18eea811 100644 --- a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx +++ b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx @@ -11,6 +11,7 @@ */ import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { @@ -28,6 +29,8 @@ import { projectEffectiveToolItem } from '../../utils/toolInvocationIdentity'; import { createLogger } from '@/shared/utils/logger'; import { isMacOSDesktopRuntime } from '@/infrastructure/runtime'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; +import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; +import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import { SessionMenu, useFlowChatSessions } from '../session-menu'; const log = createLogger('ToolbarMode'); @@ -46,7 +49,16 @@ export const ToolbarMode: React.FC = () => { } = useToolbarModeContext(); const [showHeaderOverflowMenu, setShowHeaderOverflowMenu] = useState(false); + const headerOverflowTriggerRef = useRef(null); const headerOverflowRef = useRef(null); + const headerOverflowLayout = useAnchoredPopoverPosition({ + open: showHeaderOverflowMenu, + anchorRef: headerOverflowTriggerRef, + popoverRef: headerOverflowRef, + preferredPlacement: 'bottom', + alignment: 'end', + gap: 4, + }); const isMacOS = useMemo(() => isMacOSDesktopRuntime(), []); const { workspacePath } = useCurrentWorkspace(); @@ -239,6 +251,7 @@ export const ToolbarMode: React.FC = () => { <> - {showHeaderOverflowMenu && ( + {showHeaderOverflowMenu && createPortal(
e.stopPropagation()} > -
+
, + getAppearanceOverlayHost(), )} ) : ( diff --git a/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.scss b/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.scss index 7f0e04bf72..6ac59bbc42 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.scss +++ b/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.scss @@ -1,3 +1,5 @@ +@use '../../component-library/styles/tokens' as tokens; + .tool-timeout-indicator { display: inline-flex; align-items: center; @@ -126,17 +128,19 @@ } .timeout-extend-popover { - position: absolute; - right: 0; - top: calc(100% + var(--bf-appearance-token-flowchat-inline-gap)); - z-index: 10; + position: fixed; + z-index: tokens.$z-popover; + box-sizing: border-box; width: max-content; - max-width: 150px; + max-width: min(150px, calc(100vw - 16px)); + max-height: calc(100vh - 16px); + overflow-y: auto; padding: var(--bf-appearance-token-flowchat-inline-gap) 0; border-radius: 6px; background: var(--bf-appearance-token-color-bg-elevated); border: 1px solid var(--bf-appearance-token-border-base); box-shadow: 0 4px 12px var(--bf-appearance-token-color-overlay-black-15); + font-family: var(--bf-appearance-token-font-family-sans); } .timeout-extend-option { diff --git a/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.test.tsx b/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.test.tsx index 7603861cba..b1c226eb1c 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.test.tsx @@ -78,6 +78,7 @@ describe('ToolTimeoutIndicator', () => { vi.stubGlobal('document', window.document); vi.stubGlobal('navigator', window.navigator); vi.stubGlobal('HTMLElement', window.HTMLElement); + vi.stubGlobal('HTMLDivElement', window.HTMLDivElement); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); container = document.createElement('div'); @@ -92,6 +93,7 @@ describe('ToolTimeoutIndicator', () => { root!.unmount(); }); } + document.querySelector('[data-bf-overlay-host="true"]')?.remove(); container?.remove(); dom?.window.close(); vi.unstubAllGlobals(); @@ -167,4 +169,29 @@ describe('ToolTimeoutIndicator', () => { expect(setSubagentTimeoutMock).toHaveBeenCalledWith('subagent-session', { type: 'disable' }); }); + + it('portals the restore options outside clipped tool cards', async () => { + await act(async () => { + root!.render(withI18n( + , + )); + }); + + const button = container!.querySelector('.timeout-ignore-btn'); + await act(async () => { + button!.dispatchEvent(new dom!.window.MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + const popover = document.querySelector('.timeout-extend-popover'); + expect(popover?.parentElement?.getAttribute('data-bf-overlay-host')).toBe('true'); + expect(popover?.style.visibility).toBe('visible'); + }); }); diff --git a/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.tsx b/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.tsx index adcf8e36a6..21ecea8d97 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.tsx @@ -1,4 +1,5 @@ import React, { useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; import { AlertCircle, CheckCircle2, @@ -6,6 +7,8 @@ import { Infinity as InfinityIcon, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; +import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import { useLiveElapsedTime } from '../hooks/useLiveElapsedTime'; import { useSubagentTimeoutControl } from '../hooks/useSubagentTimeoutControl'; import './ToolTimeoutIndicator.scss'; @@ -95,13 +98,28 @@ export const ToolTimeoutIndicator: React.FC = ({ ); remainingMsRef.current = remainingMs; + const controlRef = useRef(null); + const triggerRef = useRef(null); const popoverRef = useRef(null); + const popoverLayout = useAnchoredPopoverPosition({ + open: isPopoverOpen, + anchorRef: triggerRef, + popoverRef, + preferredPlacement: 'bottom', + alignment: 'end', + gap: 4, + layoutRevision: remainingAtDisable, + }); // Close popover on outside click. useEffect(() => { if (!isPopoverOpen) return; const handleClick = (e: MouseEvent) => { - if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { + const target = e.target as Node; + if ( + !controlRef.current?.contains(target) + && !popoverRef.current?.contains(target) + ) { closePopover(); } }; @@ -195,8 +213,9 @@ export const ToolTimeoutIndicator: React.FC = ({ {canControlTimeout && ( -
+
- {isPopoverOpen && ( -
+ {isPopoverOpen && createPortal( +
{remainingAtDisable > 0 ? ( -
+
, + getAppearanceOverlayHost(), )}
)} diff --git a/src/web-ui/src/infrastructure/config/components/AIFeaturesConfig.scss b/src/web-ui/src/infrastructure/config/components/AIFeaturesConfig.scss index 329c00716c..880c4b8f4d 100644 --- a/src/web-ui/src/infrastructure/config/components/AIFeaturesConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/AIFeaturesConfig.scss @@ -152,10 +152,6 @@ min-height: 38px; } - &__pet-select .select__dropdown { - max-height: 320px; - } - &__pet-actions { display: flex; align-items: center; @@ -372,6 +368,10 @@ } +.bitfun-func-agent-config__pet-select-dropdown { + max-height: min(320px, calc(100vh - 16px)); +} + @media (prefers-reduced-motion: reduce) { .bitfun-func-agent-config { &__pet-expand-button, diff --git a/src/web-ui/src/infrastructure/config/components/AIModelConfig.scss b/src/web-ui/src/infrastructure/config/components/AIModelConfig.scss index 2473e34c6c..33d8c39b6f 100644 --- a/src/web-ui/src/infrastructure/config/components/AIModelConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/AIModelConfig.scss @@ -1405,25 +1405,6 @@ font-size: 12px; } - .bitfun-ai-model-config__selected-model-category-select { - .select__dropdown { - left: 0; - right: auto; - width: max-content; - min-width: max(100%, 176px); - max-width: min(240px, calc(100vw - 64px)); - } - - .select__option-text { - min-width: max-content; - } - - .select__option-label { - overflow: visible; - text-overflow: clip; - } - } - &--switch { align-items: flex-start; @@ -1845,6 +1826,21 @@ } } +.bitfun-ai-model-config__selected-model-category-dropdown { + width: max-content; + min-width: min(176px, calc(100vw - 16px)); + max-width: min(240px, calc(100vw - 16px)); + + .select__option-text { + min-width: max-content; + } + + .select__option-label { + overflow: visible; + text-overflow: clip; + } +} + diff --git a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx index c1759947d7..7a041c344e 100644 --- a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx @@ -2198,6 +2198,8 @@ const AIModelConfig: React.FC = () => { options={categoryOptions} size="small" className="bitfun-ai-model-config__selected-model-category-select" + dropdownClassName="bitfun-ai-model-config__selected-model-category-dropdown" + dropdownMatchTriggerWidth={false} renderValue={(option) => { if (!option || Array.isArray(option)) { return null; diff --git a/src/web-ui/src/infrastructure/config/components/DefaultModelConfig.tsx b/src/web-ui/src/infrastructure/config/components/DefaultModelConfig.tsx index 41f0ef4e7d..e0259894a9 100644 --- a/src/web-ui/src/infrastructure/config/components/DefaultModelConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/DefaultModelConfig.tsx @@ -184,6 +184,7 @@ export const DefaultModelConfig: React.FC = () => { renderOption={renderModelOption} renderValue={renderModelValue} className="model-select-presentation__select" + dropdownClassName="model-select-presentation__dropdown" disabled={enabledModels.length === 0} size="small" /> @@ -207,6 +208,7 @@ export const DefaultModelConfig: React.FC = () => { renderOption={renderModelOption} renderValue={renderModelValue} className="model-select-presentation__select" + dropdownClassName="model-select-presentation__dropdown" size="small" /> @@ -229,6 +231,7 @@ export const DefaultModelConfig: React.FC = () => { renderOption={renderModelOption} renderValue={renderModelValue} className="model-select-presentation__select" + dropdownClassName="model-select-presentation__dropdown" size="small" /> diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx index 7db67d9258..a0f9d18351 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx @@ -2439,8 +2439,9 @@ describe('ExternalSourcesConfig', () => { expect(updateIntegrationPolicyMock).not.toHaveBeenCalled(); expect(document.body.textContent).toContain('policy.resetConfirmTitle'); - const confirm = Array.from(document.body.querySelectorAll('button')).filter((button) => - button.textContent === 'policy.backupAndReset').at(-1); + const dialog = document.body.querySelector('[role="dialog"]'); + const confirm = Array.from(dialog?.querySelectorAll('button') ?? []).find((button) => + button.textContent === 'policy.backupAndReset'); await act(async () => confirm?.click()); expect(updateIntegrationPolicyMock).toHaveBeenCalledWith('D:/workspace/project', { expectedPreferenceRevision: 9, diff --git a/src/web-ui/src/infrastructure/config/components/ModelSelectPresentation.scss b/src/web-ui/src/infrastructure/config/components/ModelSelectPresentation.scss index 0c81efdf4b..3d0ed6e730 100644 --- a/src/web-ui/src/infrastructure/config/components/ModelSelectPresentation.scss +++ b/src/web-ui/src/infrastructure/config/components/ModelSelectPresentation.scss @@ -14,10 +14,6 @@ align-self: center; } - .select__option { - padding: 8px 10px; - border-radius: 6px; - } } &__value { @@ -77,3 +73,8 @@ color: rgba(var(--model-select-thinking-rgb), 0.9); } } + +.model-select-presentation__dropdown .select__option { + padding: 8px 10px; + border-radius: 6px; +} diff --git a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx index b5c309000e..48220465d2 100644 --- a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx @@ -915,6 +915,7 @@ const SessionSettingsPanels: React.FC = ({ variant } >