import { useActionMutation, useActionQuery, } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { IconArrowsExchange, IconChevronDown, IconChevronRight, IconCommand, IconCopy, IconDeviceDesktop, IconDownload, IconKeyboard, IconLoader2, IconMicrophone2, IconPlayerPlay, IconPlayerStop, } from "@tabler/icons-react"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { CaptureInstallButton } from "@/components/capture-install-options"; import { VocabularySection } from "@/components/dictate/vocabulary-section"; import { PageHeader } from "@/components/library/page-header"; import { DayHeader } from "@/components/meetings/day-header"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { useDesktopPromo } from "@/hooks/use-desktop-promo"; import enMessages from "@/i18n/en-US"; import { cn, shortcutLabel, shortcutModifierLabel } from "@/lib/utils"; export function meta() { return [{ title: enMessages.dictateRoute.pageTitle }]; } interface Dictation { id: string; fullText: string; cleanedText?: string | null; durationMs?: number | null; audioUrl?: string | null; source?: "fn-hold" | "cmd-shift-space" | string; createdAt: string; } type SourceFilter = "all" | "fn-hold" | "cmd-shift-space" | "manual"; type BrowserDictationSource = "manual" | "cmd-shift-space"; interface SpeechRecognitionAlternative { transcript: string; } interface SpeechRecognitionResultLike { isFinal: boolean; [index: number]: SpeechRecognitionAlternative | undefined; } interface SpeechRecognitionResultListLike { length: number; [index: number]: SpeechRecognitionResultLike | undefined; } interface SpeechRecognitionEventLike { resultIndex: number; results: SpeechRecognitionResultListLike; } interface SpeechRecognitionErrorEventLike { error?: string; } interface SpeechRecognitionLike { continuous: boolean; interimResults: boolean; lang: string; onresult: ((event: SpeechRecognitionEventLike) => void) | null; onerror: ((event: SpeechRecognitionErrorEventLike) => void) | null; onend: (() => void) | null; start: () => void; stop: () => void; abort: () => void; } type SpeechRecognitionConstructor = new () => SpeechRecognitionLike; function getSpeechRecognitionCtor(): SpeechRecognitionConstructor | null { if (typeof window === "undefined") return null; const w = window as typeof window & { SpeechRecognition?: SpeechRecognitionConstructor; webkitSpeechRecognition?: SpeechRecognitionConstructor; }; return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null; } function isEditableTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false; const tag = target.tagName.toLowerCase(); return ( tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable ); } function formatDuration(ms?: number | null): string { if (!ms || ms <= 0) return "—"; const total = Math.round(ms / 1000); if (total < 60) return `${total}s`; const m = Math.floor(total / 60); const s = total % 60; return `${m}m ${s.toString().padStart(2, "0")}s`; } function formatTime(iso: string): string { try { return new Date(iso).toLocaleTimeString([], { hour: "numeric", minute: "2-digit", }); } catch { return iso; } } function dayBucket(iso: string): string { try { const d = new Date(iso); const today = new Date(); const startOfDay = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime(); const ms = 24 * 60 * 60 * 1000; const diff = Math.round((startOfDay(today) - startOfDay(d)) / ms); if (diff === 0) return "Today"; if (diff === 1) return "Yesterday"; if (diff > 1 && diff <= 6) { return d.toLocaleDateString([], { weekday: "long", month: "short", day: "numeric", }); } return d.toLocaleDateString([], { month: "short", day: "numeric" }); } catch { return "Earlier"; } } function sourceMeta( source: string | undefined, t: ReturnType, ): { label: string; icon: React.ReactNode; } { switch (source) { case "fn-hold": return { label: t("dictateRoute.holdFn"), icon: , }; case "cmd-shift-space": return { label: shortcutLabel("cmd+shift+space"), icon: , }; case "manual": return { label: t("dictateRoute.browserDictation"), icon: , }; default: return { label: source ?? "Voice", icon: , }; } } async function copyToClipboard(text: string, label: string): Promise { try { await navigator.clipboard.writeText(text); toast.success(`Copied ${label}`); } catch { toast.error("Couldn't copy"); } } function Kbd({ children }: { children: React.ReactNode }) { return ( {children} ); } function HowToCard({ defaultOpen = true }: { defaultOpen?: boolean }) { const t = useT(); const [open, setOpen] = useState(defaultOpen); return (
{t("dictateRoute.howToUse")}
{open ? ( ) : ( )}
{t("dictateRoute.quickNoteTitle")}

{t("dictateRoute.browserDictationDescriptionDesktop")}

{t("dictateRoute.desktopShortcuts")}

Hold Fn anywhere on your Mac, or use{" "} {shortcutModifierLabel()} Space {t("dictateRoute.desktopShortcutsDescriptionSuffix")}

); } function FilterTabs({ value, onChange, counts, }: { value: SourceFilter; onChange: (next: SourceFilter) => void; counts: Record; }) { const t = useT(); const tabs: Array<{ id: SourceFilter; label: string }> = [ { id: "all", label: "All" }, { id: "manual", label: t("dictateRoute.browserDictation") }, { id: "fn-hold", label: t("dictateRoute.holdFn") }, { id: "cmd-shift-space", label: shortcutLabel("cmd+shift+space") }, ]; return (
{tabs.map((t) => { const active = value === t.id; return ( ); })}
); } function WebDictationPanel({ supported, listening, saving, draftText, interimText, isDesktopApp, onStart, onStop, }: { supported: boolean; listening: boolean; saving: boolean; draftText: string; interimText: string; isDesktopApp: boolean; onStart: () => void; onStop: () => void; }) { const t = useT(); const preview = [draftText, interimText].filter(Boolean).join(" ").trim(); return (
{isDesktopApp ? t("dictateRoute.quickNoteTitle") : t("dictateRoute.browserDictation")}

{isDesktopApp ? ( t("dictateRoute.quickNoteHint") ) : ( <> Press{" "} {shortcutModifierLabel()} Space {" "} while this tab is focused to toggle. )}

{!supported ? (
{t("dictateRoute.browserUnavailable")}
) : listening || preview ? (
{listening && ( )} {listening ? "Listening" : "Last capture"}

{preview || ( {t("dictateRoute.startSpeaking")} )}

) : null}
); } function DictationRow({ dictation }: { dictation: Dictation }) { const t = useT(); const [expanded, setExpanded] = useState(false); const qc = useQueryClient(); const cleanup = useActionMutation("cleanup-dictation"); const replaceOriginal = useActionMutation< any, { id: string; fullText: string } >("update-dictation"); const { label, icon } = sourceMeta(dictation.source, t); const preview = (dictation.cleanedText || dictation.fullText || "").slice( 0, 140, ); const handleCleanup = (e: React.MouseEvent) => { e.stopPropagation(); cleanup.mutate( { id: dictation.id }, { onSuccess: () => { qc.invalidateQueries({ queryKey: ["action", "list-dictations"] }); }, }, ); }; const handleReplaceOriginal = (e: React.MouseEvent) => { e.stopPropagation(); if (!dictation.cleanedText) return; const next = dictation.cleanedText; // Optimistic — patch the list cache immediately. qc.setQueryData(["action", "list-dictations", {}], (prev: any) => { if (!prev) return prev; const list: Dictation[] = Array.isArray(prev) ? prev : prev.dictations; if (!list) return prev; const updated = list.map((d) => d.id === dictation.id ? { ...d, fullText: next } : d, ); return Array.isArray(prev) ? updated : { ...prev, dictations: updated }; }); replaceOriginal.mutate( { id: dictation.id, fullText: next }, { onSuccess: () => { toast.success(t("dictateRoute.replacedOriginal")); qc.invalidateQueries({ queryKey: ["action", "list-dictations"] }); }, onError: () => { toast.error("Couldn't replace"); qc.invalidateQueries({ queryKey: ["action", "list-dictations"] }); }, }, ); }; return (
setExpanded((v) => !v)} >
{expanded ? ( ) : ( )} {formatTime(dictation.createdAt)}
{icon} {label}
{preview || ( {t("dictateRoute.noText")} )}
{formatDuration(dictation.durationMs)}
Copy
{expanded && (
Original

{dictation.fullText || ( {t("dictateRoute.emptyTranscript")} )}

Cleaned
{dictation.cleanedText && ( <> {t("dictateRoute.replaceOriginal")} )}

{dictation.cleanedText || ( {t("dictateRoute.cleanupHint")} )}

)}
); } function EmptyState({ isDesktopApp }: { isDesktopApp: boolean }) { const t = useT(); return (

{t("dictateRoute.startFirst")}

{isDesktopApp ? ( <>

{t("dictateRoute.emptyDesktopDescription", { fnKey: "Fn", modifierKey: shortcutModifierLabel(), })}

) : ( <>

{t("dictateRoute.emptyWebDescription")}

{t("captureInstall.openDesktopApp")} } > {t("dictateRoute.downloadDesktopApp")}
Fn {t("dictateRoute.holdToDictate")} · {shortcutModifierLabel()} Space {t("dictateRoute.toggle")}
)}
); } function DownloadDesktopAppCard() { const t = useT(); return (
{t("dictateRoute.desktopCtaTitle")}

{t("dictateRoute.desktopCtaDescription", { modifierKey: shortcutModifierLabel(), })}

); } export default function DictateRoute() { const t = useT(); const { data, isLoading, isError } = useActionQuery< { dictations: Dictation[] } | Dictation[] | undefined >("list-dictations", {}, { retry: false }); const { isDesktopApp } = useDesktopPromo(); const [filter, setFilter] = useState("all"); const [listening, setListening] = useState(false); const [draftText, setDraftText] = useState(""); const [interimText, setInterimText] = useState(""); const [speechSupported, setSpeechSupported] = useState(false); const qc = useQueryClient(); const createDictation = useActionMutation("create-dictation"); const recognitionRef = useRef(null); const transcriptRef = useRef(""); const interimRef = useRef(""); const startedAtRef = useRef(0); const startedAtIsoRef = useRef(""); const sourceRef = useRef("manual"); const saveOnEndRef = useRef(false); const finishingRef = useRef(false); useEffect(() => { setSpeechSupported(getSpeechRecognitionCtor() !== null); }, []); const finishBrowserDictation = useCallback(() => { if (finishingRef.current) return; finishingRef.current = true; const shouldSave = saveOnEndRef.current; saveOnEndRef.current = false; setListening(false); const text = [transcriptRef.current, interimRef.current] .filter(Boolean) .join(" ") .trim(); setDraftText(text); setInterimText(""); interimRef.current = ""; if (!shouldSave) { finishingRef.current = false; return; } if (!text) { toast.error(t("dictateRoute.noSpeechCaptured")); finishingRef.current = false; return; } const durationMs = startedAtRef.current > 0 ? Date.now() - startedAtRef.current : 0; createDictation.mutate( { fullText: text, durationMs, source: sourceRef.current, startedAt: startedAtIsoRef.current || new Date().toISOString(), }, { onSuccess: () => { toast.success(t("dictateRoute.dictationSaved")); qc.invalidateQueries({ queryKey: ["action", "list-dictations"] }); }, onError: (err: Error) => { toast.error(err.message || "Couldn't save dictation"); }, onSettled: () => { finishingRef.current = false; }, }, ); }, [createDictation, qc]); const stopBrowserDictation = useCallback(() => { const recognition = recognitionRef.current; if (!recognition) { finishBrowserDictation(); return; } try { recognition.stop(); } catch { finishBrowserDictation(); } }, [finishBrowserDictation]); const startBrowserDictation = useCallback( (source: BrowserDictationSource = "manual") => { if (listening || createDictation.isPending) return; const Recognition = getSpeechRecognitionCtor(); if (!Recognition) { toast.error(t("dictateRoute.browserUnavailableShort")); return; } const recognition = new Recognition(); recognition.continuous = true; recognition.interimResults = true; recognition.lang = navigator.language || "en-US"; recognitionRef.current = recognition; transcriptRef.current = ""; interimRef.current = ""; startedAtRef.current = Date.now(); startedAtIsoRef.current = new Date().toISOString(); sourceRef.current = source; saveOnEndRef.current = true; finishingRef.current = false; setDraftText(""); setInterimText(""); recognition.onresult = (event) => { let finalText = transcriptRef.current; let interim = ""; for (let i = event.resultIndex; i < event.results.length; i++) { const result = event.results[i]; const text = result?.[0]?.transcript ?? ""; if (!text) continue; if (result?.isFinal) finalText = `${finalText} ${text}`.trim(); else interim = `${interim} ${text}`.trim(); } transcriptRef.current = finalText; interimRef.current = interim; setDraftText(finalText); setInterimText(interim); }; recognition.onerror = (event) => { const error = event.error ?? "speech-recognition"; if (error !== "no-speech" && error !== "aborted") { toast.error( error === "not-allowed" ? "Allow microphone access to dictate in the browser" : `Dictation error: ${error}`, ); } }; recognition.onend = () => { if (recognitionRef.current === recognition) { recognitionRef.current = null; } finishBrowserDictation(); }; try { recognition.start(); setListening(true); } catch (err) { recognitionRef.current = null; saveOnEndRef.current = false; finishingRef.current = false; toast.error(err instanceof Error ? err.message : "Couldn't start"); } }, [createDictation.isPending, finishBrowserDictation, listening], ); useEffect(() => { // Inside the desktop app the global Rust shortcut owns Cmd+Shift+Space, so // the in-page handler must run only in a plain browser to avoid firing // dictation twice. if (isDesktopApp) return; function onKeyDown(event: KeyboardEvent) { if (isEditableTarget(event.target)) return; if ( (event.metaKey || event.ctrlKey) && event.shiftKey && event.code === "Space" ) { event.preventDefault(); if (listening) stopBrowserDictation(); else startBrowserDictation("cmd-shift-space"); } } window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [isDesktopApp, listening, startBrowserDictation, stopBrowserDictation]); useEffect(() => { return () => { saveOnEndRef.current = false; try { recognitionRef.current?.abort(); } catch { // ignore } recognitionRef.current = null; }; }, []); const dictations: Dictation[] = useMemo(() => { if (!data) return []; if (Array.isArray(data)) return data; return data.dictations ?? []; }, [data]); const counts = useMemo>(() => { const c: Record = { all: dictations.length, "fn-hold": 0, "cmd-shift-space": 0, manual: 0, }; for (const d of dictations) { if (d.source === "fn-hold") c["fn-hold"]++; else if (d.source === "cmd-shift-space") c["cmd-shift-space"]++; else if (d.source === "manual") c.manual++; } return c; }, [dictations]); const filtered = useMemo(() => { if (filter === "all") return dictations; return dictations.filter((d) => d.source === filter); }, [dictations, filter]); const grouped = useMemo>(() => { const map = new Map(); // Already comes back desc; keep order. for (const d of filtered) { const k = dayBucket(d.createdAt); const arr = map.get(k) ?? []; arr.push(d); map.set(k, arr); } return Array.from(map.entries()); }, [filtered]); const isEmpty = !isLoading && !isError && dictations.length === 0; return ( <>

Dictate

{t("dictateRoute.voiceToTextDescription")}

{isDesktopApp ? ( <> startBrowserDictation("manual")} onStop={stopBrowserDictation} /> ) : ( )} {isLoading ? (
{Array.from({ length: 5 }).map((_, i) => ( ))}
) : isError ? (
{t("dictateRoute.loadFailed")}
) : isEmpty ? ( ) : ( <> {filtered.length === 0 ? (
{t("dictateRoute.noFilterMatches")}
) : (
{grouped.map(([day, items]) => (
When
Source
Text
Duration
{items.map((d) => ( ))}
))}
)} )}
); }