mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
@@ -201,4 +201,91 @@ describe('MicCloudComposer', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /start recording/i }));
|
||||
expect(onError).toHaveBeenCalledWith(expect.stringMatching(/not available/i));
|
||||
});
|
||||
|
||||
// ── Spacebar shortcut (#1471) ────────────────────────────────────────────
|
||||
|
||||
it('spacebar starts recording when idle and stops + submits on second press', async () => {
|
||||
transcribeCloudMock.mockResolvedValueOnce('voice via space');
|
||||
const onSubmit = vi.fn();
|
||||
render(<MicCloudComposer disabled={false} onSubmit={onSubmit} />);
|
||||
|
||||
fireEvent.keyDown(window, { code: 'Space' });
|
||||
await waitFor(() => expect(getUserMediaMock).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /stop recording and send/i })).toBeInTheDocument()
|
||||
);
|
||||
|
||||
fireEvent.keyDown(window, { code: 'Space' });
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('voice via space'));
|
||||
});
|
||||
|
||||
it('spacebar ignores key repeat so holding the key does not flap the recorder', () => {
|
||||
render(<MicCloudComposer disabled={false} onSubmit={vi.fn()} />);
|
||||
fireEvent.keyDown(window, { code: 'Space', repeat: true });
|
||||
expect(getUserMediaMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('spacebar ignores modifier combinations so Shift-Space etc. stay free', () => {
|
||||
render(<MicCloudComposer disabled={false} onSubmit={vi.fn()} />);
|
||||
fireEvent.keyDown(window, { code: 'Space', shiftKey: true });
|
||||
fireEvent.keyDown(window, { code: 'Space', ctrlKey: true });
|
||||
fireEvent.keyDown(window, { code: 'Space', metaKey: true });
|
||||
fireEvent.keyDown(window, { code: 'Space', altKey: true });
|
||||
expect(getUserMediaMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('spacebar does not trigger when focus is inside a text input', () => {
|
||||
render(
|
||||
<>
|
||||
<input data-testid="text-field" type="text" />
|
||||
<MicCloudComposer disabled={false} onSubmit={vi.fn()} />
|
||||
</>
|
||||
);
|
||||
const input = screen.getByTestId('text-field');
|
||||
input.focus();
|
||||
fireEvent.keyDown(input, { code: 'Space' });
|
||||
expect(getUserMediaMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('spacebar does not trigger when focus is inside a textarea', () => {
|
||||
render(
|
||||
<>
|
||||
<textarea data-testid="ta" />
|
||||
<MicCloudComposer disabled={false} onSubmit={vi.fn()} />
|
||||
</>
|
||||
);
|
||||
const ta = screen.getByTestId('ta');
|
||||
ta.focus();
|
||||
fireEvent.keyDown(ta, { code: 'Space' });
|
||||
expect(getUserMediaMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('spacebar does not trigger when focus is inside a contenteditable surface', () => {
|
||||
render(
|
||||
<>
|
||||
<div data-testid="ce" contentEditable suppressContentEditableWarning>
|
||||
x
|
||||
</div>
|
||||
<MicCloudComposer disabled={false} onSubmit={vi.fn()} />
|
||||
</>
|
||||
);
|
||||
const ce = screen.getByTestId('ce');
|
||||
ce.focus();
|
||||
fireEvent.keyDown(ce, { code: 'Space' });
|
||||
expect(getUserMediaMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('spacebar is a no-op while the composer is disabled', () => {
|
||||
render(<MicCloudComposer disabled={true} onSubmit={vi.fn()} />);
|
||||
fireEvent.keyDown(window, { code: 'Space' });
|
||||
expect(getUserMediaMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes the window keydown listener on unmount', () => {
|
||||
const removeSpy = vi.spyOn(window, 'removeEventListener');
|
||||
const { unmount } = render(<MicCloudComposer disabled={false} onSubmit={vi.fn()} />);
|
||||
unmount();
|
||||
expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function));
|
||||
removeSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,6 +85,86 @@ export function MicCloudComposer({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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
|
||||
// first, but it bails out when focus is inside an editable control or
|
||||
// a button so the shortcut never steals a keystroke from real input.
|
||||
useEffect(() => {
|
||||
function shouldIgnoreFocus(target: EventTarget | null): boolean {
|
||||
// Non-HTMLElement targets (SVG nodes, `document` itself) are
|
||||
// never text inputs, so the spacebar shortcut is safe to fire —
|
||||
// returning `false` here means "do not suppress".
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'BUTTON') {
|
||||
return true;
|
||||
}
|
||||
// contenteditable surfaces (rich-text composers, ProseMirror,
|
||||
// etc.). `target.isContentEditable` is the right check in real
|
||||
// browsers because it walks the inheritance chain, but jsdom
|
||||
// doesn't compute the flag for plain `<div contenteditable>`,
|
||||
// so we additionally walk up via `closest` to cover both the
|
||||
// jsdom + production case.
|
||||
if (target.isContentEditable) return true;
|
||||
const editableAncestor = target.closest('[contenteditable]');
|
||||
if (editableAncestor instanceof HTMLElement) {
|
||||
const value = editableAncestor.getAttribute('contenteditable');
|
||||
// `contenteditable=""` and `contenteditable="true"` both mean
|
||||
// editable; `"false"` explicitly opts out.
|
||||
if (value === '' || value === 'true' || value === 'plaintext-only') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
// `event.code` is layout-independent — `'Space'` is the physical
|
||||
// bar key on every layout, where `event.key === ' '` would also
|
||||
// match remaps that shouldn't trigger voice. Stick to `code`.
|
||||
if (event.code !== 'Space') return;
|
||||
// Don't fight repeat-key autorepeat — the toggle should be edge-
|
||||
// triggered, not continuous.
|
||||
if (event.repeat) return;
|
||||
// Bare spacebar only. Modifier combinations (Shift-Space etc.) are
|
||||
// owned by the rest of the app and must keep flowing through.
|
||||
if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
if (shouldIgnoreFocus(event.target ?? document.activeElement)) {
|
||||
composerLog(
|
||||
'spacebar ignored — focus inside editable target=%s',
|
||||
(event.target as HTMLElement | null)?.tagName ?? '<non-html>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Prevent the default page-scroll behaviour and any focused-button
|
||||
// click activation (the user might be tabbed onto the mic button
|
||||
// itself, which would otherwise fire twice).
|
||||
event.preventDefault();
|
||||
if (disabled || state === 'transcribing') {
|
||||
composerLog('spacebar ignored — disabled=%s state=%s', disabled, state);
|
||||
return;
|
||||
}
|
||||
composerLog('spacebar toggle state=%s', state);
|
||||
if (state === 'recording') {
|
||||
stopRecording();
|
||||
} else {
|
||||
void startRecording();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
// `state` is the only changing dependency the handler reads; the
|
||||
// refs are stable and `disabled` is captured via closure. Re-binding
|
||||
// on every state transition is cheap and keeps the snapshot in sync.
|
||||
// `startRecording` / `stopRecording` are plain function declarations
|
||||
// hoisted inside the component body — their identity is stable within
|
||||
// each render, so omitting them from the dep list is intentional, not
|
||||
// a stale-closure risk.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state, disabled]);
|
||||
|
||||
function stopStream() {
|
||||
if (streamRef.current) {
|
||||
for (const track of streamRef.current.getTracks()) {
|
||||
|
||||
Reference in New Issue
Block a user