From e388b2227c21103ed88e45aff9bbb9653ba9327a Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 2 Dec 2025 10:31:53 -0600 Subject: [PATCH] fix: move recording keybinds into useVoiceInput hook More correct by construction: the hook owns the state machine, so it should own the keyboard interactions for that state. - Add useRecordingKeybinds option to useVoiceInput - Track spaceHeld in a ref that persists correctly - Listeners added only when state === 'recording', auto-cleanup on state change - Removes duplicate keybind code from ChatInput --- src/browser/components/ChatInput/index.tsx | 18 ++------ src/browser/hooks/useVoiceInput.ts | 50 ++++++++++++++++++++++ 2 files changed, 53 insertions(+), 15 deletions(-) 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 // ---------------------------------------------------------------------------