diff --git a/desktop/src/components/chat/ComposerCapabilityMenu.test.tsx b/desktop/src/components/chat/ComposerCapabilityMenu.test.tsx index 3d24730f..402cec51 100644 --- a/desktop/src/components/chat/ComposerCapabilityMenu.test.tsx +++ b/desktop/src/components/chat/ComposerCapabilityMenu.test.tsx @@ -183,6 +183,43 @@ describe('ComposerCapabilityMenu', () => { expect(onAction).toHaveBeenCalledWith({ type: 'insertMention', reference: designSkill }) }) + it('opens the side panel level with its row and keeps it inside the window', () => { + const rowTops: Record = { Skills: 160, Connectors: 192 } + let flyoutHeight = 120 + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + const label = this.getAttribute('role') === 'option' ? Object.keys(rowTops).find(key => this.querySelector(`#${this.id}-label`)?.textContent === key) : undefined + const top = label ? rowTops[label]! : this.className.includes('bottom-full') ? 100 : 0 + return { top, height: label ? 32 : 0, bottom: top + (label ? 32 : 0), left: 0, right: 0, width: 0, x: 0, y: top, toJSON: () => ({}) } as DOMRect + }) + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (this: HTMLElement) { + return this.dataset.testid === 'capability-flyout' ? flyoutHeight : 0 + }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 }) + try { + renderMenu() + // Row top relative to the menu, title bar centred on the 32px row. + fireEvent.mouseEnter(screen.getByRole('option', { name: /Skills/ })) + expect(screen.getByTestId('capability-flyout').style.top).toBe('56px') + fireEvent.mouseEnter(screen.getByRole('option', { name: /Connectors/ })) + expect(screen.getByTestId('capability-flyout').style.top).toBe('88px') + + // Near the window bottom it lifts just enough to stay 8px clear. + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 300 }) + fireEvent.mouseEnter(screen.getByRole('option', { name: /Skills/ })) + expect(screen.getByTestId('capability-flyout').style.top).toBe('56px') + fireEvent.mouseEnter(screen.getByRole('option', { name: /Connectors/ })) + expect(screen.getByTestId('capability-flyout').style.top).toBe('72px') + + // A panel taller than the window keeps its top edge on screen. + flyoutHeight = 400 + fireEvent.mouseEnter(screen.getByRole('option', { name: /Skills/ })) + expect(screen.getByTestId('capability-flyout').style.top).toBe('-92px') + } finally { + vi.restoreAllMocks() + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 768 }) + } + }) + it('walks into a category with the keyboard and steps back one panel per Escape', () => { const { onAction, onClose } = renderMenu() const input = searchInput() diff --git a/desktop/src/components/chat/ComposerCapabilityMenu.tsx b/desktop/src/components/chat/ComposerCapabilityMenu.tsx index 50eff49b..2ef23823 100644 --- a/desktop/src/components/chat/ComposerCapabilityMenu.tsx +++ b/desktop/src/components/chat/ComposerCapabilityMenu.tsx @@ -35,6 +35,10 @@ type Props = { const ROOT_WIDTH = 288 const FLYOUT_WIDTH = 320 const PANEL_GAP = 4 +/** The side panel's title bar, centred on the row that opened it. */ +const FLYOUT_HEADER_HEIGHT = 40 +/** Space the side panel keeps from the window edges. */ +const VIEWPORT_MARGIN = 8 /** The full skill list reuses the @ menu's reference browser. */ const BROWSE_REFERENCES_KEY = 'skills:all' const PANEL = 'overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]' @@ -98,6 +102,8 @@ export function ComposerCapabilityMenu({ id, sections, cwd = '', referencesLoadi // categories in place, as the sheet does. const [narrow, setNarrow] = useState(false) const containerRef = useRef(null) + const flyoutRef = useRef(null) + const [flyoutTop, setFlyoutTop] = useState(0) const referenceRef = useRef(null) useLayoutEffect(() => { if (sheet) return @@ -136,6 +142,32 @@ export function ComposerCapabilityMenu({ id, sections, cwd = '', referencesLoadi ? subIndex < 0 ? undefined : getCapabilitySubMenuOptionId(id, subIndex) : rootIndex < 0 ? undefined : getCapabilityMenuOptionId(id, rootIndex) const controlsId = showReferences ? referencesId : drillParent ? subListId : listId + const flyoutAnchorIndex = flyoutOpen ? rootItems.findIndex(item => item.key === path[0]) : -1 + + // Like a desktop submenu, the side panel opens level with its row and only + // moves off it when it would otherwise run past the window. + useLayoutEffect(() => { + const container = containerRef.current + const flyout = flyoutRef.current + if (!container || !flyout || flyoutAnchorIndex < 0) return + const doc = container.ownerDocument + const place = () => { + const row = doc.getElementById(getCapabilityMenuOptionId(id, flyoutAnchorIndex)) + if (!row) return + const containerTop = container.getBoundingClientRect().top + const rowRect = row.getBoundingClientRect() + const viewHeight = doc.defaultView?.innerHeight ?? Infinity + const level = rowRect.top - containerTop + (rowRect.height - FLYOUT_HEADER_HEIGHT) / 2 + const lowest = viewHeight - VIEWPORT_MARGIN - flyout.offsetHeight - containerTop + setFlyoutTop(Math.max(VIEWPORT_MARGIN - containerTop, Math.min(level, lowest))) + } + place() + const View = doc.defaultView + if (!View?.ResizeObserver) return + const observer = new View.ResizeObserver(place) + observer.observe(flyout) + return () => observer.disconnect() + }, [id, flyoutAnchorIndex, path.length, browseReferences]) const openCategory = (nextPath: string[]) => { setPath(nextPath) @@ -308,12 +340,12 @@ export function ComposerCapabilityMenu({ id, sections, cwd = '', referencesLoadi } - return
event.preventDefault()}> -
+ return
event.preventDefault()}> +
{searchRow} {searching ? referenceMenu : rootList}
- {flyoutOpen ?
+ {flyoutOpen ?
{path.length > 1 ? } label={t('chat.capabilities.back')} size="xs" tone="muted" onClick={goBack} /> : null} {drillParent!.label}