Skip to content

Commit 0d42beb

Browse files
authored
🤖 fix: voice input keybind improvements (#846)
_Generated with mux_ Fixes several voice input UX issues: **Space key hold causing gibberish sends** - Track `spaceHeld` in a ref, require release before space can trigger send - Prevents rapid start→send when user holds space **Ctrl+D not working globally during recording** - Moved recording keybinds into `useVoiceInput` hook (owns state machine, should own keybinds) - Uses `matchesKeybind(e, KEYBINDS.TOGGLE_VOICE_INPUT)` as source of truth **Space requiring focus to send** - Use capture phase (`addEventListener(..., true)`) to intercept before focused elements consume the event - Now space/escape/ctrl+d work regardless of focus during recording Design is more correct by construction: listeners only added when `state === 'recording'`, auto-cleanup on state change.
1 parent 38d0f2c commit 0d42beb

File tree

2 files changed

+53
-15
lines changed

2 files changed

+53
-15
lines changed

src/browser/components/ChatInput/index.tsx

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
173173
},
174174
onSend: () => void handleSend(),
175175
openAIKeySet,
176+
useRecordingKeybinds: true,
176177
});
177178

178179
// Start creation tutorial when entering creation mode
@@ -496,23 +497,9 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
496497
voiceInput.toggle();
497498
};
498499

499-
// Global keybinds only active during recording
500-
const handleKeyDown = (e: KeyboardEvent) => {
501-
if (voiceInput.state !== "recording") return;
502-
if (e.key === " ") {
503-
e.preventDefault();
504-
voiceInput.stop({ send: true });
505-
} else if (e.key === "Escape") {
506-
e.preventDefault();
507-
voiceInput.cancel();
508-
}
509-
};
510-
511500
window.addEventListener(CUSTOM_EVENTS.TOGGLE_VOICE_INPUT, handleToggle as EventListener);
512-
window.addEventListener("keydown", handleKeyDown);
513501
return () => {
514502
window.removeEventListener(CUSTOM_EVENTS.TOGGLE_VOICE_INPUT, handleToggle as EventListener);
515-
window.removeEventListener("keydown", handleKeyDown);
516503
};
517504
}, [voiceInput, setToast]);
518505

@@ -862,9 +849,10 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
862849
return;
863850
}
864851

865-
// Space on empty input starts voice recording
852+
// Space on empty input starts voice recording (ignore key repeat from holding)
866853
if (
867854
e.key === " " &&
855+
!e.repeat &&
868856
input.trim() === "" &&
869857
voiceInput.shouldShowUI &&
870858
voiceInput.isApiKeySet &&

src/browser/hooks/useVoiceInput.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import { useState, useCallback, useRef, useEffect } from "react";
10+
import { matchesKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
1011

1112
export type VoiceInputState = "idle" | "recording" | "transcribing";
1213

@@ -16,6 +17,13 @@ export interface UseVoiceInputOptions {
1617
/** Called after successful transcription if stop({ send: true }) was used */
1718
onSend?: () => void;
1819
openAIKeySet: boolean;
20+
/**
21+
* When true, hook manages global keybinds during recording:
22+
* - Space: stop and send (requires release after start)
23+
* - Escape: cancel
24+
* - Ctrl+D / Cmd+D: stop without sending
25+
*/
26+
useRecordingKeybinds?: boolean;
1927
}
2028

2129
export interface UseVoiceInputResult {
@@ -236,6 +244,48 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul
236244
};
237245
}, [releaseStream]);
238246

247+
// ---------------------------------------------------------------------------
248+
// Recording keybinds (when useRecordingKeybinds is true)
249+
// ---------------------------------------------------------------------------
250+
251+
// Track if space is held to prevent start→send when user holds space
252+
const spaceHeldRef = useRef(false);
253+
254+
useEffect(() => {
255+
if (!options.useRecordingKeybinds || state !== "recording") {
256+
spaceHeldRef.current = false;
257+
return;
258+
}
259+
260+
// Assume space is held when recording starts (conservative default)
261+
spaceHeldRef.current = true;
262+
263+
const handleKeyUp = (e: KeyboardEvent) => {
264+
if (e.key === " ") spaceHeldRef.current = false;
265+
};
266+
267+
const handleKeyDown = (e: KeyboardEvent) => {
268+
if (e.key === " " && !spaceHeldRef.current) {
269+
e.preventDefault();
270+
stop({ send: true });
271+
} else if (e.key === "Escape") {
272+
e.preventDefault();
273+
cancel();
274+
} else if (matchesKeybind(e, KEYBINDS.TOGGLE_VOICE_INPUT)) {
275+
e.preventDefault();
276+
stop();
277+
}
278+
};
279+
280+
// Use capture phase to intercept before focused elements consume the event
281+
window.addEventListener("keyup", handleKeyUp, true);
282+
window.addEventListener("keydown", handleKeyDown, true);
283+
return () => {
284+
window.removeEventListener("keyup", handleKeyUp, true);
285+
window.removeEventListener("keydown", handleKeyDown, true);
286+
};
287+
}, [options.useRecordingKeybinds, state, stop, cancel]);
288+
239289
// ---------------------------------------------------------------------------
240290
// Return
241291
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)