/** * Voice dictation button + recording overlay for the agent composer. * * UX mirrors Lovable: click-to-toggle record, a live amplitude bar and * MM:SS timer replace the editor area while recording, and a cancel X * discards without transcribing. The mic is always visible alongside the * send button (Cursor replaces send with mic; their users complain — we * don't copy that). */ import { IconMicrophone, IconPlayerStopFilled, IconLoader2, IconX, } from "@tabler/icons-react"; import { useCallback, useEffect, useState } from "react"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip.js"; import { RealtimeVoiceModeEntry } from "./RealtimeVoiceMode.js"; import type { RealtimeVoiceInputMode } from "./RealtimeVoiceMode.js"; import { useComposerRuntimeAdapters } from "./runtime-adapters.js"; import { useRealtimeVoiceModeCopy, useRealtimeVoiceModeOptional, } from "./useRealtimeVoiceMode.js"; import type { VoiceDictationApi } from "./useVoiceDictation.js"; const VOICE_INPUT_PREFERENCE_KEY = "voice-input-preference"; export function normalizeVoiceInputPreference( value: unknown, ): RealtimeVoiceInputMode | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const mode = (value as { mode?: unknown }).mode; return mode === "realtime" || mode === "dictation" ? mode : null; } function openOpenAiKeySettings(): void { window.location.hash = "#secrets:OPENAI_API_KEY"; window.dispatchEvent(new Event("agent-panel:open")); window.dispatchEvent( new CustomEvent("agent-panel:open-settings", { detail: { section: "secrets" }, }), ); } export interface VoiceButtonProps { voice: VoiceDictationApi; isMac: boolean; disabled?: boolean; } export function isRealtimeVoiceSetupRequired( status: { builder?: boolean; openai?: boolean } | null, builderConfigured: boolean | null, ): boolean { return ( status !== null && !status.builder && !status.openai && builderConfigured !== true ); } export function VoiceButton({ voice, isMac, disabled }: VoiceButtonProps) { const adapters = useComposerRuntimeAdapters(); const { state, start, stop, supported } = voice; const realtimeVoice = useRealtimeVoiceModeOptional(); const realtimeCopy = useRealtimeVoiceModeCopy(); const voiceProviders = adapters.voice!.useProviderStatus!(); const builderConnect = adapters.builder!.useConnectFlow!({ trackingSource: "realtime_voice", trackingFlow: "voice_transcription", onConnected: () => voiceProviders.refresh(), }); const [inputPreference, setInputPreference] = useState<{ ready: boolean; mode: RealtimeVoiceInputMode | null; }>({ ready: false, mode: null }); useEffect(() => { let cancelled = false; void Promise.resolve( adapters.voice!.readAppState!(VOICE_INPUT_PREFERENCE_KEY), ) .then((value) => { if (!cancelled) { setInputPreference({ ready: true, mode: normalizeVoiceInputPreference(value), }); } }) .catch(() => { if (!cancelled) setInputPreference({ ready: true, mode: null }); }); return () => { cancelled = true; }; }, [adapters]); const rememberInputPreference = useCallback( (mode: RealtimeVoiceInputMode) => { setInputPreference({ ready: true, mode }); void Promise.resolve( adapters.voice!.setAppState!(VOICE_INPUT_PREFERENCE_KEY, { mode }), ).catch(() => undefined); }, [adapters], ); if (!supported) return null; const recording = state === "recording" || state === "starting"; const transcribing = state === "transcribing"; if (realtimeVoice?.active && !recording && !transcribing) return null; if (realtimeVoice && !recording && !transcribing) { return ( { if (voiceProviders.status?.openai) void realtimeVoice.start(); else openOpenAiKeySettings(); }} onStartVoiceMode={() => void realtimeVoice.start()} onKeepDictating={() => void start()} preferredMode={inputPreference.ready ? inputPreference.mode : null} onRememberPreference={rememberInputPreference} /> ); } const label = recording ? "Stop recording" : transcribing ? "Transcribing…" : `Dictate (${isMac ? "⌘⇧M" : "Ctrl+Shift+M"})`; const onClick = () => { if (recording) stop(); else if (!transcribing) void start(); }; return ( {label} ); } export interface VoiceRecordingOverlayProps { voice: VoiceDictationApi; } export function VoiceRecordingOverlay({ voice }: VoiceRecordingOverlayProps) { const { state, amplitude, durationMs, errorMessage, cancel } = voice; const { dismissError, start } = voice; if (state === "error" && errorMessage) { return (
{errorMessage} Try again Dismiss
); } if (state !== "recording" && state !== "starting" && state !== "transcribing") return null; return (
Cancel (Esc)
{state === "transcribing" ? ( Transcribing… ) : ( )}
{state === "transcribing" ? ( ) : ( formatDuration(durationMs) )}
); } const BAR_COUNT = 24; function AmplitudeBars({ amplitude }: { amplitude: number }) { // Render a symmetric meter — the middle bars peak first so the visual // matches what voice input looks like in Lovable / iOS dictation. const bars = []; for (let i = 0; i < BAR_COUNT; i++) { const centerDistance = Math.abs(i - (BAR_COUNT - 1) / 2) / ((BAR_COUNT - 1) / 2); const heightPct = Math.max(0.1, amplitude * (1 - centerDistance * 0.6)) * 100; bars.push( , ); } return <>{bars}; } function formatDuration(ms: number): string { const total = Math.floor(ms / 1000); const m = Math.floor(total / 60); const s = total % 60; return `${m}:${s.toString().padStart(2, "0")}`; }