mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(human): keep mic device selector inside the viewport (#4343)
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user