diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index aad5a0f818..c2f6eb5d61 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -173,6 +173,7 @@ export const ChatInput: React.FC = (props) => { }, onSend: () => void handleSend(), openAIKeySet, + useRecordingKeybinds: true, }); // Start creation tutorial when entering creation mode @@ -496,23 +497,9 @@ export const ChatInput: React.FC = (props) => { voiceInput.toggle(); }; - // Global keybinds only active during recording - const handleKeyDown = (e: KeyboardEvent) => { - if (voiceInput.state !== "recording") return; - if (e.key === " ") { - e.preventDefault(); - voiceInput.stop({ send: true }); - } else if (e.key === "Escape") { - e.preventDefault(); - voiceInput.cancel(); - } - }; - window.addEventListener(CUSTOM_EVENTS.TOGGLE_VOICE_INPUT, handleToggle as EventListener); - window.addEventListener("keydown", handleKeyDown); return () => { window.removeEventListener(CUSTOM_EVENTS.TOGGLE_VOICE_INPUT, handleToggle as EventListener); - window.removeEventListener("keydown", handleKeyDown); }; }, [voiceInput, setToast]); @@ -862,9 +849,10 @@ export const ChatInput: React.FC = (props) => { return; } - // Space on empty input starts voice recording + // Space on empty input starts voice recording (ignore key repeat from holding) if ( e.key === " " && + !e.repeat && input.trim() === "" && voiceInput.shouldShowUI && voiceInput.isApiKeySet && diff --git a/src/browser/hooks/useVoiceInput.ts b/src/browser/hooks/useVoiceInput.ts index 3ce6821be9..b5ce2e51cd 100644 --- a/src/browser/hooks/useVoiceInput.ts +++ b/src/browser/hooks/useVoiceInput.ts @@ -7,6 +7,7 @@ */ import { useState, useCallback, useRef, useEffect } from "react"; +import { matchesKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; export type VoiceInputState = "idle" | "recording" | "transcribing"; @@ -16,6 +17,13 @@ export interface UseVoiceInputOptions { /** Called after successful transcription if stop({ send: true }) was used */ onSend?: () => void; openAIKeySet: boolean; + /** + * When true, hook manages global keybinds during recording: + * - Space: stop and send (requires release after start) + * - Escape: cancel + * - Ctrl+D / Cmd+D: stop without sending + */ + useRecordingKeybinds?: boolean; } export interface UseVoiceInputResult { @@ -236,6 +244,48 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul }; }, [releaseStream]); + // --------------------------------------------------------------------------- + // Recording keybinds (when useRecordingKeybinds is true) + // --------------------------------------------------------------------------- + + // Track if space is held to prevent start→send when user holds space + const spaceHeldRef = useRef(false); + + useEffect(() => { + if (!options.useRecordingKeybinds || state !== "recording") { + spaceHeldRef.current = false; + return; + } + + // Assume space is held when recording starts (conservative default) + spaceHeldRef.current = true; + + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === " ") spaceHeldRef.current = false; + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === " " && !spaceHeldRef.current) { + e.preventDefault(); + stop({ send: true }); + } else if (e.key === "Escape") { + e.preventDefault(); + cancel(); + } else if (matchesKeybind(e, KEYBINDS.TOGGLE_VOICE_INPUT)) { + e.preventDefault(); + stop(); + } + }; + + // Use capture phase to intercept before focused elements consume the event + window.addEventListener("keyup", handleKeyUp, true); + window.addEventListener("keydown", handleKeyDown, true); + return () => { + window.removeEventListener("keyup", handleKeyUp, true); + window.removeEventListener("keydown", handleKeyDown, true); + }; + }, [options.useRecordingKeybinds, state, stop, cancel]); + // --------------------------------------------------------------------------- // Return // ---------------------------------------------------------------------------