import { useAgentChatGenerating } from "@agent-native/core/client/agent-chat"; import { useT } from "@agent-native/core/client/i18n"; import { IconMicrophone, IconMicrophoneOff, IconLoader2, } from "@tabler/icons-react"; import { useState, useRef, useCallback, useEffect } from "react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; interface VoiceDictationProps { currentDate: Date; } type VoiceState = "idle" | "listening" | "processing"; export function VoiceDictation({ currentDate }: VoiceDictationProps) { const t = useT(); const [state, setState] = useState("idle"); const [transcript, setTranscript] = useState(""); const recognitionRef = useRef(null); const isProcessingRef = useRef(false); const [isGenerating, sendToAgent] = useAgentChatGenerating(); const isSupported = typeof window !== "undefined" && ("SpeechRecognition" in window || "webkitSpeechRecognition" in (window as any)); // Track sidebar width to shift mic button out of the way const [sidebarWidth, setSidebarWidth] = useState(0); useEffect(() => { const measure = () => { const panel = document.querySelector(".agent-sidebar-panel"); const w = panel ? panel.getBoundingClientRect().width : 0; setSidebarWidth(w); }; // Use ResizeObserver to react to sidebar resize drags const observer = new ResizeObserver(measure); const startObserving = () => { const panel = document.querySelector(".agent-sidebar-panel"); if (panel) observer.observe(panel); else setSidebarWidth(0); }; // Re-attach observer when sidebar opens/closes const onToggle = () => setTimeout(startObserving, 100); startObserving(); window.addEventListener("agent-panel:toggle", onToggle); window.addEventListener("agent-panel:open", onToggle); return () => { observer.disconnect(); window.removeEventListener("agent-panel:toggle", onToggle); window.removeEventListener("agent-panel:open", onToggle); }; }, []); // When agent finishes generating, transition back to idle useEffect(() => { if (!isGenerating && state === "processing") { setState("idle"); setTranscript(""); toast.success(t("voice.done")); } }, [isGenerating, state]); const processCommand = useCallback( (text: string) => { setState("processing"); try { // Open the agent sidebar now that the voice message is captured. // Always open a fresh chat tab so each voice command gets its own thread. window.dispatchEvent(new Event("agent-panel:open")); sendToAgent({ message: text, submit: true, newTab: true }); // Timeout fallback: if sidebar is closed or event never fires, // don't leave the mic stuck in processing forever setTimeout(() => { setState((s) => (s === "processing" ? "idle" : s)); setTranscript((t) => (t ? "" : t)); }, 15000); } catch (error) { console.error("Error sending voice command:", error); toast.error(t("voice.processFailed")); setState("idle"); setTranscript(""); } }, [sendToAgent], ); const startListening = useCallback(() => { if (!isSupported) { toast.error(t("voice.unsupported")); return; } if (recognitionRef.current) { try { recognitionRef.current.abort(); } catch {} recognitionRef.current = null; } isProcessingRef.current = false; setState("listening"); setTranscript(""); const SpeechRecognitionCtor = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; const recognition: any = new SpeechRecognitionCtor(); recognition.continuous = false; recognition.interimResults = true; recognition.lang = "en-US"; recognition.maxAlternatives = 1; recognition.onstart = () => setState("listening"); recognition.onresult = (event: any) => { const current = event.resultIndex; const result = event.results[current]; const transcriptText = result[0].transcript; setTranscript(transcriptText); if (result.isFinal && !isProcessingRef.current) { isProcessingRef.current = true; try { recognition.stop(); } catch {} processCommand(transcriptText); } }; recognition.onerror = (event: any) => { console.error("Speech recognition error:", event.error); if (!isProcessingRef.current) { setState("idle"); setTranscript(""); } if (event.error === "not-allowed") { toast.error(t("voice.microphoneDenied"), { description: t("voice.allowMicrophone"), }); } else if (event.error === "no-speech") { toast.error(t("voice.noSpeech"), { description: t("voice.tryAgain"), }); } else if (event.error !== "aborted") { toast.error(t("voice.captureFailed")); } }; recognition.onend = () => { if (!isProcessingRef.current) setState("idle"); recognitionRef.current = null; }; recognitionRef.current = recognition; try { recognition.start(); } catch { setState("idle"); toast.error(t("voice.startFailed")); } }, [isSupported, processCommand]); const stopListening = useCallback(() => { if (recognitionRef.current) { try { recognitionRef.current.stop(); } catch {} } setState("idle"); }, []); const handleClick = useCallback(() => { if (state === "idle") startListening(); else if (state === "listening") stopListening(); }, [state, startListening, stopListening]); useEffect(() => { return () => { if (recognitionRef.current) recognitionRef.current.abort(); }; }, []); if (!isSupported) return null; return (
0 ? { right: `${sidebarWidth + 24}px` } : undefined} > {(state === "listening" || state === "processing") && (
{state === "listening" && (
{transcript || t("voice.listening")}
)} {state === "processing" && (
{t("voice.processing", { transcript })}
)}
)} {state === "idle" && (

{t("voice.tapToSpeak")}

)}
); }