feat(human): toggle voice recording with spacebar (#1471) (#1483)

This commit is contained in:
obchain
2026-05-11 09:27:19 -07:00
committed by GitHub
parent b5bedd6b2c
commit 89d7c54c7a
2 changed files with 167 additions and 0 deletions
@@ -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()) {