diff --git a/app/src/features/human/MicComposer.test.tsx b/app/src/features/human/MicComposer.test.tsx index 8d93b3ee0..42d418cf1 100644 --- a/app/src/features/human/MicComposer.test.tsx +++ b/app/src/features/human/MicComposer.test.tsx @@ -49,6 +49,27 @@ function makeFakeRecorder(mime: string): FakeRecorder { const fakeStream = { getTracks: () => [{ stop: vi.fn() }] } as unknown as MediaStream; +// jsdom never lays elements out, so getBoundingClientRect returns all-zero +// rects. The device-menu placement logic measures the trigger and the menu, so +// tests stub those rects per-element to simulate viewport positions. +function makeRect(p: Partial): DOMRect { + const top = p.top ?? 0; + const left = p.left ?? 0; + const width = p.width ?? 0; + const height = p.height ?? 0; + return { + top, + left, + width, + height, + bottom: p.bottom ?? top + height, + right: p.right ?? left + width, + x: left, + y: top, + toJSON: () => ({}), + } as DOMRect; +} + describe('MicComposer', () => { let recorder: FakeRecorder; let getUserMediaMock: ReturnType; @@ -568,6 +589,97 @@ describe('MicComposer', () => { expect(screen.getByText('Tap and speak')).toBeInTheDocument(); }); + // ── Device menu placement (#4264: overlay clipped at viewport bottom) ─────── + + async function openDeviceMenuWithRects(triggerRect: Partial) { + const enumerateDevicesMock = vi.fn().mockResolvedValue([ + { kind: 'audioinput', deviceId: 'dev1', label: 'Built-in Mic' }, + { kind: 'audioinput', deviceId: 'dev2', label: 'USB Headset' }, + ]); + Object.defineProperty(globalThis.navigator, 'mediaDevices', { + value: { getUserMedia: getUserMediaMock, enumerateDevices: enumerateDevicesMock }, + configurable: true, + writable: true, + }); + render(); + + const gearBtn = await screen.findByLabelText(/Microphone device/i); + // Per-instance override shadows the prototype, so only the trigger reports + // this rect; the menu falls back to the prototype stub below. + gearBtn.getBoundingClientRect = () => makeRect(triggerRect); + // Menu measures 256×300 (its CSS width is w-64 = 256px); stub a tall list. + const menuRectSpy = vi + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function (this: HTMLElement) { + if (this.getAttribute('role') === 'menu') { + return makeRect({ top: 0, left: 0, width: 256, height: 300 }); + } + return makeRect({}); + }); + fireEvent.click(gearBtn); + const menu = await screen.findByRole('menu'); + return { menu, menuRectSpy }; + } + + it('flips the device menu above the gear when there is no room below', async () => { + vi.stubGlobal('innerHeight', 600); + vi.stubGlobal('innerWidth', 1000); + // Trigger sits near the bottom edge: only 8px below, but 560px above. + const { menu, menuRectSpy } = await openDeviceMenuWithRects({ + top: 560, + bottom: 592, + left: 100, + width: 32, + height: 32, + }); + try { + // top = trigger.top(560) − margin(8) − menuHeight(300) = 252, fully on-screen. + expect(parseFloat(menu.style.top)).toBe(252); + expect(parseFloat(menu.style.top)).toBeLessThan(560); + expect(menu.style.visibility).toBe('visible'); + } finally { + menuRectSpy.mockRestore(); + } + }); + + it('opens the device menu below the gear when there is room', async () => { + vi.stubGlobal('innerHeight', 600); + vi.stubGlobal('innerWidth', 1000); + // Trigger near the top: 518px of room below comfortably fits the 300px menu. + const { menu, menuRectSpy } = await openDeviceMenuWithRects({ + top: 50, + bottom: 82, + left: 100, + width: 32, + height: 32, + }); + try { + // top = trigger.bottom(82) + margin(8) = 90. + expect(parseFloat(menu.style.top)).toBe(90); + } finally { + menuRectSpy.mockRestore(); + } + }); + + it('clamps the device menu horizontally inside the viewport', async () => { + vi.stubGlobal('innerHeight', 600); + vi.stubGlobal('innerWidth', 1000); + // Trigger hugs the right edge — centring the 256px menu would overflow. + const { menu, menuRectSpy } = await openDeviceMenuWithRects({ + top: 50, + bottom: 82, + left: 980, + width: 32, + height: 32, + }); + try { + // left clamps to viewportW(1000) − menuWidth(256) − margin(8) = 736. + expect(parseFloat(menu.style.left)).toBe(736); + } finally { + menuRectSpy.mockRestore(); + } + }); + // ── STT retry (#1206) ────────────────────────────────────────────────────── it('retries transient STT failures before falling back to WAV', async () => { diff --git a/app/src/features/human/MicComposer.tsx b/app/src/features/human/MicComposer.tsx index 6755bbb14..992a83f01 100644 --- a/app/src/features/human/MicComposer.tsx +++ b/app/src/features/human/MicComposer.tsx @@ -1,5 +1,5 @@ import debug from 'debug'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useT } from '../../lib/i18n/I18nContext'; @@ -165,6 +165,12 @@ export function MicComposer({ const [selectedDeviceId, setSelectedDeviceId] = useState(''); const [deviceMenuOpen, setDeviceMenuOpen] = useState(false); const gearButtonRef = useRef(null); + const menuRef = useRef(null); + // Final viewport coordinates for the portaled device menu. Computed AFTER the + // menu mounts (see positionDeviceMenu) so we can measure its real height and + // flip it above the trigger when there isn't room below — otherwise the list + // clips off the bottom of the screen when the mic UI sits near the viewport + // edge. `null` while the menu is mounted-but-unmeasured (kept invisible). const [menuAnchor, setMenuAnchor] = useState<{ top: number; left: number } | null>(null); const recorderRef = useRef(null); const streamRef = useRef(null); @@ -229,6 +235,55 @@ export function MicComposer({ return () => navigator.mediaDevices?.removeEventListener?.('devicechange', onDeviceChange); }, [showDeviceSelector]); + // Boundary-aware placement for the device menu. The menu is portaled to + // `document.body` and positioned `fixed`, so it must clamp itself inside the + // viewport: open below the gear when there's room, otherwise flip above it, + // and horizontally centre-then-clamp. Runs after the menu mounts (and on + // resize/scroll while open) so it can measure the menu's real size — a fixed + // `rect.bottom + 8` anchor clips the list off-screen near the bottom edge. + const positionDeviceMenu = useCallback(() => { + const trigger = gearButtonRef.current?.getBoundingClientRect(); + const menu = menuRef.current?.getBoundingClientRect(); + if (!trigger || !menu) return; + const MARGIN = 8; + const viewportH = window.innerHeight; + const viewportW = window.innerWidth; + const spaceBelow = viewportH - trigger.bottom; + const spaceAbove = trigger.top; + // Prefer below; flip above only when below can't fit the menu AND above has + // more room. Either way the final `top` is clamped into the viewport so the + // scrollable max-height fallback keeps every option reachable. + const flipUp = spaceBelow < menu.height + MARGIN && spaceAbove > spaceBelow; + let top = flipUp ? trigger.top - MARGIN - menu.height : trigger.bottom + MARGIN; + top = Math.max(MARGIN, Math.min(top, viewportH - menu.height - MARGIN)); + let left = trigger.left + trigger.width / 2 - menu.width / 2; + left = Math.max(MARGIN, Math.min(left, viewportW - menu.width - MARGIN)); + composerLog( + 'positioned device menu: placement=%s top=%d left=%d (spaceBelow=%d spaceAbove=%d menuH=%d)', + flipUp ? 'above' : 'below', + Math.round(top), + Math.round(left), + Math.round(spaceBelow), + Math.round(spaceAbove), + Math.round(menu.height) + ); + setMenuAnchor({ top, left }); + }, []); + + useLayoutEffect(() => { + if (!deviceMenuOpen) return; + positionDeviceMenu(); + window.addEventListener('resize', positionDeviceMenu); + // Capture-phase scroll so the menu tracks any scrolling ancestor, not just + // the window, and stays clamped to the viewport while the page moves. + window.addEventListener('scroll', positionDeviceMenu, true); + return () => { + window.removeEventListener('resize', positionDeviceMenu); + window.removeEventListener('scroll', positionDeviceMenu, true); + }; + // devices.length re-measures when the option count (and thus height) changes. + }, [deviceMenuOpen, devices.length, positionDeviceMenu]); + // Spacebar = tap-to-toggle (#1471). Scoped to whatever surface mounts // this composer — today only the Human agent page. The listener lives // on the window so the user doesn't have to click the mascot stage @@ -749,14 +804,14 @@ export function MicComposer({ {deviceMenuOpen && - menuAnchor && createPortal( <>