diff --git a/app/src/features/human/MicCloudComposer.test.tsx b/app/src/features/human/MicCloudComposer.test.tsx
index 77aad8561..68357510f 100644
--- a/app/src/features/human/MicCloudComposer.test.tsx
+++ b/app/src/features/human/MicCloudComposer.test.tsx
@@ -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();
+
+ 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();
+ fireEvent.keyDown(window, { code: 'Space', repeat: true });
+ expect(getUserMediaMock).not.toHaveBeenCalled();
+ });
+
+ it('spacebar ignores modifier combinations so Shift-Space etc. stay free', () => {
+ render();
+ 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(
+ <>
+
+
+ >
+ );
+ 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(
+ <>
+
+
+ >
+ );
+ 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(
+ <>
+
+ x
+
+
+ >
+ );
+ 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();
+ 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();
+ unmount();
+ expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function));
+ removeSpy.mockRestore();
+ });
});
diff --git a/app/src/features/human/MicCloudComposer.tsx b/app/src/features/human/MicCloudComposer.tsx
index 967d70c7f..1e4960c06 100644
--- a/app/src/features/human/MicCloudComposer.tsx
+++ b/app/src/features/human/MicCloudComposer.tsx
@@ -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 `
`,
+ // 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 ?? ''
+ );
+ 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()) {