fix(human): keep mic device selector inside the viewport (#4343)

This commit is contained in:
Cyrus Gray
2026-06-30 18:54:12 +05:30
committed by GitHub
parent 5853aaa36c
commit 7a524720c7
2 changed files with 183 additions and 12 deletions
+112
View File
@@ -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>): 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<typeof vi.fn>;
@@ -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<DOMRect>) {
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(<MicComposer disabled={false} onSubmit={vi.fn()} showDeviceSelector />);
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 () => {
+71 -12
View File
@@ -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<string>('');
const [deviceMenuOpen, setDeviceMenuOpen] = useState(false);
const gearButtonRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(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<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(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({
<button
ref={gearButtonRef}
type="button"
aria-label={t('mic.deviceSelector') || 'Microphone device'}
aria-label={t('mic.deviceSelector')}
aria-expanded={deviceMenuOpen}
onClick={() => {
const rect = gearButtonRef.current?.getBoundingClientRect();
if (rect) {
setMenuAnchor({ top: rect.bottom + 8, left: rect.left + rect.width / 2 });
}
setDeviceMenuOpen(open => !open);
const willOpen = !deviceMenuOpen;
// Hide the menu until positionDeviceMenu measures it this open
// cycle, so it never flashes at the previous cycle's anchor.
if (willOpen) setMenuAnchor(null);
setDeviceMenuOpen(willOpen);
}}
disabled={state !== 'idle'}
className="w-8 h-8 flex items-center justify-center rounded-full border border-line bg-surface text-content-muted hover:text-content-secondary hover:border-line-strong dark:hover:border-neutral-600 transition-colors shadow-soft disabled:opacity-40 disabled:cursor-not-allowed">
@@ -780,7 +835,6 @@ export function MicComposer({
</svg>
</button>
{deviceMenuOpen &&
menuAnchor &&
createPortal(
<>
<div
@@ -790,13 +844,18 @@ export function MicComposer({
aria-hidden
/>
<div
ref={menuRef}
role="menu"
aria-label={t('mic.deviceSelector') || 'Microphone device'}
aria-label={t('mic.deviceSelector')}
style={{
position: 'fixed',
top: menuAnchor.top,
left: menuAnchor.left,
transform: 'translateX(-50%)',
top: menuAnchor?.top ?? 0,
left: menuAnchor?.left ?? 0,
// Kept out of view (but laid out, so it can be measured)
// until positionDeviceMenu computes the clamped anchor.
visibility: menuAnchor ? 'visible' : 'hidden',
maxHeight: 'calc(100vh - 16px)',
overflowY: 'auto',
zIndex: 99999,
}}
className="w-64 rounded-xl border border-line bg-surface shadow-soft py-1">