diff --git a/site/src/hooks/useSpeechRecognition.test.ts b/site/src/hooks/useSpeechRecognition.test.ts index ef8b906555..0e8c9f1054 100644 --- a/site/src/hooks/useSpeechRecognition.test.ts +++ b/site/src/hooks/useSpeechRecognition.test.ts @@ -233,4 +233,106 @@ describe("useSpeechRecognition", () => { expect(first?.abort).toHaveBeenCalled(); expect(lastInstance).not.toBe(first); }); + + it("start() ignores onend from a previously aborted instance", () => { + installMock(); + const { result } = renderHook(() => useSpeechRecognition()); + + // Start recording — creates the first instance. + act(() => { + result.current.start(); + }); + const first = lastInstance!; + + // Override abort so it does NOT fire onend synchronously, + // simulating the async browser behaviour. + first.abort = vi.fn(); + + // Start recording again — creates a second instance and aborts + // the first. + act(() => { + result.current.start(); + }); + const second = lastInstance!; + + expect(first.abort).toHaveBeenCalled(); + expect(result.current.isRecording).toBe(true); + + // Simulate the OLD instance's async onend firing late. + act(() => { + first.onend?.(); + }); + + // The old onend must be ignored — recording is still active. + expect(result.current.isRecording).toBe(true); + expect(lastInstance).toBe(second); + }); + + it("exposes error from onerror event", () => { + installMock(); + const { result } = renderHook(() => useSpeechRecognition()); + + expect(result.current.error).toBeNull(); + + act(() => { + result.current.start(); + }); + + act(() => { + lastInstance?.onerror?.({ + error: "not-allowed", + message: "Permission denied", + }); + }); + + expect(result.current.error).toBe("not-allowed"); + expect(result.current.isRecording).toBe(false); + }); + + it("start() clears previous error", () => { + installMock(); + const { result } = renderHook(() => useSpeechRecognition()); + + act(() => { + result.current.start(); + }); + + act(() => { + lastInstance?.onerror?.({ + error: "not-allowed", + message: "Permission denied", + }); + }); + expect(result.current.error).toBe("not-allowed"); + + act(() => { + result.current.start(); + }); + + expect(result.current.error).toBeNull(); + expect(result.current.isRecording).toBe(true); + }); + + it("cancel() clears error", () => { + installMock(); + const { result } = renderHook(() => useSpeechRecognition()); + + act(() => { + result.current.start(); + }); + + act(() => { + lastInstance?.onerror?.({ + error: "not-allowed", + message: "Permission denied", + }); + }); + expect(result.current.error).toBe("not-allowed"); + + act(() => { + result.current.cancel(); + }); + + expect(result.current.error).toBeNull(); + }); }); diff --git a/site/src/hooks/useSpeechRecognition.ts b/site/src/hooks/useSpeechRecognition.ts index 0ce8994fe0..d8b89906d0 100644 --- a/site/src/hooks/useSpeechRecognition.ts +++ b/site/src/hooks/useSpeechRecognition.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; // Inline type declarations for the Web Speech API, which is not covered // by TypeScript's built-in lib types in all environments. @@ -75,12 +75,14 @@ export function useSpeechRecognition(): { isSupported: boolean; isRecording: boolean; transcript: string; + error: string | null; start: () => void; stop: () => void; cancel: () => void; } { const [isRecording, setIsRecording] = useState(false); const [transcript, setTranscript] = useState(""); + const [error, setError] = useState(null); const recognitionRef = useRef(null); // Cache the constructor lookup once per hook instance so we don't hit @@ -103,6 +105,8 @@ export function useSpeechRecognition(): { recognitionRef.current = null; } + setError(null); + const recognition = new Ctor(); recognition.lang = navigator.language; recognition.continuous = true; @@ -126,12 +130,15 @@ export function useSpeechRecognition(): { setTranscript(finalizedText + interim); }; - recognition.onerror = () => { + recognition.onerror = (event: SpeechRecognitionErrorEvent) => { + if (recognitionRef.current !== recognition) return; + setError(event.error); setIsRecording(false); recognitionRef.current = null; }; recognition.onend = () => { + if (recognitionRef.current !== recognition) return; setIsRecording(false); recognitionRef.current = null; }; @@ -160,7 +167,17 @@ export function useSpeechRecognition(): { } setIsRecording(false); setTranscript(""); + setError(null); }, []); - return { isSupported, isRecording, transcript, start, stop, cancel }; + useEffect(() => { + return () => { + if (recognitionRef.current) { + recognitionRef.current.abort(); + recognitionRef.current = null; + } + }; + }, []); + + return { isSupported, isRecording, transcript, error, start, stop, cancel }; } diff --git a/site/src/pages/AgentsPage/AgentChatInput.tsx b/site/src/pages/AgentsPage/AgentChatInput.tsx index c3fe104efe..3da9d340c4 100644 --- a/site/src/pages/AgentsPage/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/AgentChatInput.tsx @@ -695,23 +695,35 @@ export const AgentChatInput = memo( )} {speech.isSupported && !isStreaming && ( - + <> + + {speech.error && !speech.isRecording && ( + + {speech.error === "not-allowed" + ? "Mic access denied" + : "Voice input failed"} + + )} + )} {contextUsage !== undefined && (