From c113faf131ca9d16577c0dd5de63b39d55e94f22 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sat, 25 Apr 2026 01:23:37 -0700 Subject: [PATCH] fix(dictate): force-dismiss the speaking pill when SSE never comes back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pill subscribed to /generation/{id}/status to know when to start playback, but EventSource.onerror was a no-op — auto-reconnect was the intended recovery for transient drops. The gap: if the backend deletes the gen row mid-flight or the connection silently dies in a way the browser keeps retrying without ever getting a status event, the pill sits in 'speaking' forever and the user has no way to clear it. Added a 60-second hard cap that arms when the SSE opens and clears the moment any real status event lands. If it fires while the pill is still on the same id and audio never started, it force-dismisses. Same idea as the existing post-speak-end 15s grace, but covers the case where the backend never says anything at all. --- .../DictateWindow/DictateWindow.tsx | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/app/src/components/DictateWindow/DictateWindow.tsx b/app/src/components/DictateWindow/DictateWindow.tsx index 42b14f04..dfc4e546 100644 --- a/app/src/components/DictateWindow/DictateWindow.tsx +++ b/app/src/components/DictateWindow/DictateWindow.tsx @@ -105,14 +105,23 @@ export function DictateWindow() { const speakingRef = useRef(null); speakingRef.current = speaking; const statusSourceRef = useRef(null); + const statusTimeoutRef = useRef(null); const audioRef = useRef(null); + const clearStatusTimeout = () => { + if (statusTimeoutRef.current !== null) { + window.clearTimeout(statusTimeoutRef.current); + statusTimeoutRef.current = null; + } + }; + const dismissSpeak = (id?: string) => { // Guard against a late dismiss targeting a stale cycle (a new speak // already started by the time audio.ended from the previous one fired). if (id && speakingRef.current && speakingRef.current.generationId !== id) return; statusSourceRef.current?.close(); statusSourceRef.current = null; + clearStatusTimeout(); if (audioRef.current) { audioRef.current.pause(); audioRef.current.src = ''; @@ -172,14 +181,28 @@ export function DictateWindow() { // `/audio/{id}` endpoint will serve the WAV we need to play. const source = new EventSource(apiClient.getGenerationStatusUrl(id)); statusSourceRef.current = source; + // Hard cap on how long the pill can sit in the 'speaking' state + // without ever hearing back from the backend. Covers the case where + // the gen row is deleted mid-flight (SSE 404s and EventSource silently + // retries) or the backend goes away while a request is in flight. + // Clears as soon as a real status event lands. + clearStatusTimeout(); + statusTimeoutRef.current = window.setTimeout(() => { + statusTimeoutRef.current = null; + if (speakingRef.current?.generationId === id && !audioRef.current) { + dismissSpeak(id); + } + }, 60_000); source.onmessage = (msg) => { try { const data = JSON.parse(msg.data) as { status?: string }; if (data.status === 'completed') { + clearStatusTimeout(); source.close(); if (statusSourceRef.current === source) statusSourceRef.current = null; startSpeakPlayback(id); } else if (data.status === 'failed' || data.status === 'not_found') { + clearStatusTimeout(); source.close(); dismissSpeak(id); } @@ -188,9 +211,8 @@ export function DictateWindow() { } }; source.onerror = () => { - // Let browser retry a few times; if it gives up, force-dismiss. - // We don't close here because EventSource auto-reconnects on - // transient drops and we'd like to keep trying. + // EventSource auto-reconnects on transient drops; the timeout above + // is the backstop for the case where it never recovers. }; }), );