diff --git a/packages/app/e2e/dictation-web.spec.ts b/packages/app/e2e/dictation-web.spec.ts index 4d04e263d..ae1af9600 100644 --- a/packages/app/e2e/dictation-web.spec.ts +++ b/packages/app/e2e/dictation-web.spec.ts @@ -2,9 +2,15 @@ import { test, expect } from './fixtures'; import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'; import { createTempGitRepo } from './helpers/workspace'; import type { Page } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; -function addFakeMicrophone(page: Page) { - return page.addInitScript(() => { +async function addFakeMicrophone(page: Page) { + const fixturePath = path.resolve(__dirname, 'fixtures', 'recording.webm'); + const base64Audio = (await readFile(fixturePath)).toString('base64'); + const mimeType = 'audio/webm;codecs=opus'; + + return page.addInitScript(({ base64Audio, mimeType }) => { const mic = { active: 0, getUserMediaCalls: 0, @@ -13,29 +19,34 @@ function addFakeMicrophone(page: Page) { }; (window as any).__mic = mic; - const ensureMediaDevices = () => { - const nav = navigator as any; - if (!nav.mediaDevices) { - nav.mediaDevices = {}; - } - if (typeof nav.mediaDevices.getUserMedia !== 'function') { - nav.mediaDevices.getUserMedia = async () => { - mic.getUserMediaCalls += 1; - mic.active += 1; - const track = { - stop: () => { - mic.stopCalls += 1; - mic.active = Math.max(0, mic.active - 1); - }, - }; - return { - getTracks: () => [track], - }; - }; - } + (window as any).isSecureContext = true; + + const nav = navigator as any; + if (!nav.mediaDevices) { + nav.mediaDevices = {}; + } + nav.mediaDevices.getUserMedia = async () => { + mic.getUserMediaCalls += 1; + mic.active += 1; + const track = { + stop: () => { + mic.stopCalls += 1; + mic.active = Math.max(0, mic.active - 1); + }, + }; + return { + getTracks: () => [track], + }; }; - ensureMediaDevices(); + const blobFromBase64 = (base64: string, mimeType: string): Blob => { + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return new Blob([bytes], { type: mimeType }); + }; class FakeMediaRecorder extends EventTarget { public static isTypeSupported() { @@ -64,7 +75,7 @@ function addFakeMicrophone(page: Page) { this.state = 'inactive'; try { this.ondataavailable?.({ - data: new Blob(['paseo-e2e-audio'], { type: this.mimeType }), + data: blobFromBase64(base64Audio, mimeType), }); } catch (err) { this.onerror?.(err); @@ -74,7 +85,7 @@ function addFakeMicrophone(page: Page) { } (window as any).MediaRecorder = FakeMediaRecorder; - }); + }, { base64Audio, mimeType }); } test('dictation hotkeys do not trigger on background screens', async ({ page }) => { @@ -88,6 +99,7 @@ test('dictation hotkeys do not trigger on background screens', async ({ page }) await createAgent(page, 'Respond with exactly: Hello'); await expect(page).toHaveURL(/\/agent\//); + await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable(); await page.keyboard.press('Control+d'); await page.waitForTimeout(200); @@ -107,6 +119,33 @@ test('dictation hotkeys do not trigger on background screens', async ({ page }) } }); +test('dictation transcribes fixture via real STT', async ({ page }) => { + await addFakeMicrophone(page); + + const repo = await createTempGitRepo(); + try { + await gotoHome(page); + await setWorkingDirectory(page, repo.path); + await ensureHostSelected(page); + await createAgent(page, 'Respond with exactly: Hello'); + + await expect(page).toHaveURL(/\/agent\//); + await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable(); + + await page.keyboard.press('Control+d'); + await expect + .poll(async () => page.evaluate(() => (window as any).__mic.active as number)) + .toBe(1); + + await page.keyboard.press('Control+d'); + + const transcriptLocator = page.getByText(/voice note/i); + await expect(transcriptLocator).toBeVisible({ timeout: 60_000 }); + } finally { + await repo.cleanup(); + } +}); + test('cancel stops mic even if recorder is already inactive', async ({ page }) => { await addFakeMicrophone(page); @@ -118,6 +157,7 @@ test('cancel stops mic even if recorder is already inactive', async ({ page }) = await createAgent(page, 'Respond with exactly: Hello'); await expect(page).toHaveURL(/\/agent\//); + await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable(); await page.keyboard.press('Control+d'); await expect @@ -139,3 +179,41 @@ test('cancel stops mic even if recorder is already inactive', async ({ page }) = await repo.cleanup(); } }); + +test('dictation confirm+send does not dispatch after navigating away', async ({ page }) => { + await addFakeMicrophone(page); + + const repo = await createTempGitRepo(); + try { + await gotoHome(page); + await setWorkingDirectory(page, repo.path); + await ensureHostSelected(page); + await createAgent(page, 'Respond with exactly: Hello'); + + await expect(page).toHaveURL(/\/agent\//); + await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable(); + + await page.keyboard.press('Control+d'); + await expect + .poll(async () => page.evaluate(() => (window as any).__mic.active as number)) + .toBe(1); + + await page.keyboard.press('Control+d'); + + const newAgentButton = page.getByText('New Agent', { exact: true }).first(); + await expect(newAgentButton).toBeVisible(); + await newAgentButton.click(); + await expect(page).toHaveURL(/\/$/); + + await page.waitForTimeout(10_000); + + const agentEntry = page.getByText(repo.path).first(); + await expect(agentEntry).toBeVisible(); + await agentEntry.click(); + await expect(page).toHaveURL(/\/agent\//); + + await expect(page.getByText(/voice note/i)).not.toBeVisible(); + } finally { + await repo.cleanup(); + } +}); diff --git a/packages/app/e2e/fixtures/recording.wav b/packages/app/e2e/fixtures/recording.wav new file mode 100644 index 000000000..8a26fca67 Binary files /dev/null and b/packages/app/e2e/fixtures/recording.wav differ diff --git a/packages/app/e2e/fixtures/recording.webm b/packages/app/e2e/fixtures/recording.webm new file mode 100644 index 000000000..a3329e3e8 Binary files /dev/null and b/packages/app/e2e/fixtures/recording.webm differ diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 529b6bfc3..993ebac96 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -16,6 +16,7 @@ import Animated, { import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useRealtime } from "@/contexts/realtime-context"; +import { useIsFocused } from "@react-navigation/native"; import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; import { generateMessageId } from "@/types/stream"; import { AgentStatusBar } from "./agent-status-bar"; @@ -77,6 +78,7 @@ export function AgentInputArea({ const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); + const isScreenFocused = useIsFocused(); const client = useSessionStore( (state) => state.sessions[serverId]?.client ?? null @@ -568,6 +570,7 @@ export function AgentInputArea({ placeholder="Message agent..." autoFocus={autoFocus} disabled={isRealtimeMode} + isScreenFocused={isScreenFocused} leftContent={leftContent} rightContent={rightContent} isAgentRunning={isAgentRunning} diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 90f2a47d3..750fd072e 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -54,6 +54,8 @@ export interface MessageInputProps { placeholder?: string; autoFocus?: boolean; disabled?: boolean; + /** True when the containing screen is focused (React Navigation). Used to disable global hotkeys and cancel dictation when unfocused. */ + isScreenFocused?: boolean; /** Content to render on the left side of the button row (e.g., AgentStatusBar) */ leftContent?: React.ReactNode; /** Content to render on the right side after voice button (e.g., realtime button, cancel button) */ @@ -105,6 +107,7 @@ export const MessageInput = forwardRef( placeholder = "Message...", autoFocus = false, disabled = false, + isScreenFocused = true, leftContent, rightContent, isAgentRunning = false, @@ -202,12 +205,20 @@ export const MessageInput = forwardRef( onError: handleDictationError, canStart: canStartDictation, canConfirm: canConfirmDictation, + autoStopWhenHidden: { isVisible: isScreenFocused }, enableDuration: true, }); + useEffect(() => { + if (isDictating || isDictationProcessing || dictationStatus === "retrying") { + return; + } + sendAfterTranscriptRef.current = false; + }, [dictationStatus, isDictating, isDictationProcessing]); + // Cmd+D to start/submit dictation, Escape to cancel useEffect(() => { - if (!IS_WEB) return; + if (!IS_WEB || !isScreenFocused) return; function handleKeyDown(event: KeyboardEvent) { // Cmd+D: start dictation or submit if already dictating if ((event.metaKey || event.ctrlKey) && event.key === "d") { diff --git a/packages/app/src/hooks/use-audio-recorder.native.ts b/packages/app/src/hooks/use-audio-recorder.native.ts index 428a38c9c..14b7bd4c8 100644 --- a/packages/app/src/hooks/use-audio-recorder.native.ts +++ b/packages/app/src/hooks/use-audio-recorder.native.ts @@ -1,6 +1,7 @@ import { useAudioRecorder as useExpoAudioRecorder, useAudioRecorderState, RecordingOptions, RecordingPresets, requestRecordingPermissionsAsync, setAudioModeAsync } from 'expo-audio'; import { Paths, File, Directory, FileInfo } from 'expo-file-system'; import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import { AttemptCancelledError, AttemptGuard } from '@/utils/attempt-guard'; export interface AudioCaptureConfig { sampleRate?: number; @@ -127,8 +128,8 @@ async function uriToBlob(uri: string): Promise { */ export function useAudioRecorder(config?: AudioCaptureConfig) { const [recordingStartTime, setRecordingStartTime] = useState(null); - const recordingIntentRef = useRef(false); - const stopRequestedRef = useRef(false); + const attemptGuardRef = useRef(new AttemptGuard()); + const startStopMutexRef = useRef | null>(null); // Store config callbacks in refs so they can update without recreating the recorder const configRef = useRef(config); @@ -203,19 +204,13 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { } try { - recordingIntentRef.current = true; - const ensureNotCancelled = () => { - if (stopRequestedRef.current) { - throw new Error('Recording cancelled'); - } - }; - - ensureNotCancelled(); + const attemptId = attemptGuardRef.current.next(); + attemptGuardRef.current.assertCurrent(attemptId); // Request microphone permissions console.log('[AudioRecorder] Requesting recording permissions...'); const permissionResponse = await requestRecordingPermissionsAsync(); - ensureNotCancelled(); + attemptGuardRef.current.assertCurrent(attemptId); if (!permissionResponse.granted) { throw new Error('Microphone permission denied. Please enable microphone access in your device settings.'); @@ -227,7 +222,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { playsInSilentMode: true, allowsRecording: true, }); - ensureNotCancelled(); + attemptGuardRef.current.assertCurrent(attemptId); console.log('[AudioRecorder] Starting recording with options:', { sampleRate: recordingOptions.sampleRate, @@ -237,25 +232,26 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { const startTime = new Date(); setRecordingStartTime(startTime); - ensureNotCancelled(); + attemptGuardRef.current.assertCurrent(attemptId); // Prepare the recorder before recording (required step) console.log('[AudioRecorder] Preparing recorder...'); await recorder.prepareToRecordAsync(); - ensureNotCancelled(); + attemptGuardRef.current.assertCurrent(attemptId); console.log('[AudioRecorder] Starting recording...'); await recorder.record(); - ensureNotCancelled(); + attemptGuardRef.current.assertCurrent(attemptId); console.log('[AudioRecorder] Recording started at:', startTime.toISOString()); console.log('[AudioRecorder] Recorder isRecording:', recorder.isRecording); } catch (error: any) { - recordingIntentRef.current = false; setRecordingStartTime(null); - if (error?.message === 'Recording cancelled') { + if (error instanceof AttemptCancelledError) { console.log('[AudioRecorder] Recording start cancelled.'); - } else { + return; + } + if (error?.message !== 'Recording cancelled') { console.error('[AudioRecorder] Failed to start recording:', error); } throw new Error(`Failed to start audio recording: ${error.message}`); @@ -265,76 +261,75 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { const stop = useCallback(async (): Promise => { const recorder = recorderRef.current; - // Guard against duplicate stop calls - if (!recorder.isRecording && !recordingIntentRef.current) { - console.error('[AudioRecorder] Attempted to stop when not recording. Recorder state:', { - isRecording: recorder.isRecording, - hasRecordingIntent: recordingIntentRef.current, - hasRecordingStartTime: !!recordingStartTime, - }); - throw new Error('Not recording'); + // Cancel any in-flight start attempt and serialize stop/cleanup. + attemptGuardRef.current.cancel(); + if (startStopMutexRef.current) { + await startStopMutexRef.current.catch(() => undefined); } - stopRequestedRef.current = true; - try { - // Stop recording - if (recorder.isRecording) { - await recorder.stop(); - } else { - console.warn('[AudioRecorder] Recorder already stopped before stop() call, continuing cleanup.'); - } - - // Get URI from recorder - let uri = recorder.uri; - console.log('[AudioRecorder] Initial URI from recorder:', uri); - - // Workaround for Expo SDK 54 Android bug - find actual recording file - if (recordingStartTime && (!uri || uri === '')) { - console.log('[AudioRecorder] Using workaround to find actual recording file...'); - const actualUri = await getActualRecordingUri(recordingStartTime); - if (actualUri) { - uri = actualUri; - console.log('[AudioRecorder] Found actual recording URI:', uri); + const stopPromise = (async () => { + // Stop recording + if (recorder.isRecording) { + await recorder.stop(); + } else { + console.warn('[AudioRecorder] Recorder already stopped before stop() call, continuing cleanup.'); } - } - if (!uri || uri === '') { - throw new Error('No recording URI found'); - } + // Get URI from recorder + let uri = recorder.uri; + console.log('[AudioRecorder] Initial URI from recorder:', uri); - // Get file info - const file = new File(uri); - const exists = file.exists; - console.log('[AudioRecorder] File exists:', exists); + // Workaround for Expo SDK 54 Android bug - find actual recording file + if (recordingStartTime && (!uri || uri === '')) { + console.log('[AudioRecorder] Using workaround to find actual recording file...'); + const actualUri = await getActualRecordingUri(recordingStartTime); + if (actualUri) { + uri = actualUri; + console.log('[AudioRecorder] Found actual recording URI:', uri); + } + } - if (!exists) { - throw new Error('Recording file does not exist'); - } + if (!uri || uri === '') { + // Cancellation / early stop: return an empty blob, but guarantee cleanup. + setRecordingStartTime(null); + return new Blob([], { type: 'audio/m4a' }); + } - // Convert URI to Blob - const audioBlob = await uriToBlob(uri); + // Get file info + const file = new File(uri); + const exists = file.exists; + console.log('[AudioRecorder] File exists:', exists); - console.log('[AudioRecorder] Recording converted to blob:', { - size: audioBlob.size, - type: audioBlob.type, - }); + if (!exists) { + setRecordingStartTime(null); + return new Blob([], { type: 'audio/m4a' }); + } - // Clean up the temporary file - file.delete(); + // Convert URI to Blob + const audioBlob = await uriToBlob(uri); - // Reset start time and clear recording intent - setRecordingStartTime(null); - recordingIntentRef.current = false; + console.log('[AudioRecorder] Recording converted to blob:', { + size: audioBlob.size, + type: audioBlob.type, + }); - return audioBlob; + // Clean up the temporary file + file.delete(); + + // Reset start time + setRecordingStartTime(null); + + return audioBlob; + })(); + startStopMutexRef.current = stopPromise; + return await stopPromise; } catch (error: any) { - recordingIntentRef.current = false; setRecordingStartTime(null); console.error('[AudioRecorder] Failed to stop recording:', error); throw new Error(`Failed to stop audio recording: ${error.message}`); } finally { - stopRequestedRef.current = false; + startStopMutexRef.current = null; } }, [recordingStartTime]); @@ -345,8 +340,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { }, []); const isRecording = useCallback(() => { - // Treat local intent as backup so callers can detect pending sessions - return Boolean(recorderState.isRecording) || recordingIntentRef.current; + return Boolean(recorderState.isRecording); }, [recorderState.isRecording]); // Return stable object using useMemo diff --git a/packages/app/src/hooks/use-audio-recorder.web.ts b/packages/app/src/hooks/use-audio-recorder.web.ts index 63e0a09dc..a458bfc25 100644 --- a/packages/app/src/hooks/use-audio-recorder.web.ts +++ b/packages/app/src/hooks/use-audio-recorder.web.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { AttemptCancelledError, AttemptGuard } from "@/utils/attempt-guard"; export interface AudioCaptureConfig { sampleRate?: number; @@ -21,6 +22,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { const mediaRecorderRef = useRef(null); const mediaStreamRef = useRef(null); + const attemptGuardRef = useRef(new AttemptGuard()); const chunksRef = useRef([]); const supportedMimeTypeRef = useRef(undefined); const analyserRef = useRef(null); @@ -79,6 +81,14 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { } }, []); + const hardReset = useCallback(() => { + stopMetering(); + cleanupStream(); + mediaRecorderRef.current = null; + chunksRef.current = []; + setIsRecording(false); + }, [cleanupStream, stopMetering]); + const startMetering = useCallback((stream: MediaStream) => { const onAudioLevel = configRef.current?.onAudioLevel; if (!onAudioLevel) { @@ -129,6 +139,8 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { }, []); const start = useCallback(async () => { + const attemptId = attemptGuardRef.current.next(); + if (mediaRecorderRef.current && mediaRecorderRef.current.state === "recording") { throw new Error("Already recording"); } @@ -173,6 +185,22 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { throw new Error(`Failed to access microphone: ${error?.message ?? error}`); } + try { + attemptGuardRef.current.assertCurrent(attemptId); + } catch (err) { + if (err instanceof AttemptCancelledError) { + stream.getTracks().forEach((track) => { + try { + track.stop(); + } catch { + // Ignore track stop errors. + } + }); + return; + } + throw err; + } + mediaStreamRef.current = stream; const recorderOptions: MediaRecorderOptions = {}; @@ -222,11 +250,25 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { }, [cleanupStream, detectSupportedMimeType, startMetering]); const stop = useCallback(async (): Promise => { + attemptGuardRef.current.cancel(); + const recorder = mediaRecorderRef.current; - if (!recorder || recorder.state === "inactive") { + if (!recorder) { throw new Error("Not recording"); } + if (recorder.state === "inactive") { + stopMetering(); + cleanupStream(); + mediaRecorderRef.current = null; + setIsRecording(false); + + const mimeType = recorder.mimeType || detectSupportedMimeType() || "audio/webm"; + const blob = new Blob(chunksRef.current, { type: mimeType }); + chunksRef.current = []; + return blob; + } + return await new Promise((resolve, reject) => { const finalize = () => { recorder.removeEventListener("stop", finalize); @@ -275,17 +317,15 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { useEffect(() => { return () => { - if (mediaRecorderRef.current && mediaRecorderRef.current.state === "recording") { - try { - mediaRecorderRef.current.stop(); - } catch { - // Ignore stop during unmount. - } + attemptGuardRef.current.cancel(); + try { + void mediaRecorderRef.current?.stop(); + } catch { + // Ignore stop during unmount. } - stopMetering(); - cleanupStream(); + hardReset(); }; - }, [cleanupStream, stopMetering]); + }, [hardReset]); const getSupportedMimeType = useCallback(() => detectSupportedMimeType(), [ detectSupportedMimeType, diff --git a/packages/app/src/hooks/use-dictation.ts b/packages/app/src/hooks/use-dictation.ts index 6ce2696ed..e4d59c906 100644 --- a/packages/app/src/hooks/use-dictation.ts +++ b/packages/app/src/hooks/use-dictation.ts @@ -5,6 +5,7 @@ import { useAudioRecorder } from "@/hooks/use-audio-recorder"; import type { DaemonClientV2 } from "@server/client/daemon-client-v2"; import type { TranscriptionResultMessage } from "@server/shared/messages"; import { generateMessageId } from "@/types/stream"; +import { AttemptGuard } from "@/utils/attempt-guard"; export type DictationStatus = "idle" | "recording" | "uploading" | "retrying" | "failed"; @@ -310,9 +311,15 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { isRecordingRef.current = isRecording; }, [isRecording]); + const isProcessingRef = useRef(isProcessing); + useEffect(() => { + isProcessingRef.current = isProcessing; + }, [isProcessing]); + const pendingRequestIdRef = useRef(null); const activeStopPromiseRef = useRef | null>(null); const durationIntervalRef = useRef | null>(null); + const attemptGuardRef = useRef(new AttemptGuard()); const stopDurationTracking = useCallback(() => { if (durationIntervalRef.current) { @@ -510,6 +517,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { isRecording: isRecordingRef.current, hasActiveStop: Boolean(activeStopPromiseRef.current), }); + attemptGuardRef.current.cancel(); if (!isRecordingRef.current && !activeStopPromiseRef.current) { console.log("[useDictation] cancelDictation ignored: nothing to cancel"); return; @@ -561,8 +569,11 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { setRetryAttempt(0); setLastOutcome(null); + const attemptId = attemptGuardRef.current.next(); + try { const audioData = await stopRecorder(); + attemptGuardRef.current.assertCurrent(attemptId); const recordedDurationSeconds = durationRef.current; pendingAudioRef.current = buildCapturedAudioPayload(audioData, recordedDurationSeconds); setStatus("uploading"); @@ -571,6 +582,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { setVolume(0); const transcription = await transmitDictation(); + attemptGuardRef.current.assertCurrent(attemptId); handleTranscriptionSuccess(transcription); } catch (err) { resetTranscriptionMutation(); @@ -661,14 +673,34 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { const prevVisible = visibilityRef.current; visibilityRef.current = nextVisible; - if (prevVisible === true && nextVisible === false && isRecordingRef.current) { - cancelRef.current?.(); + if (prevVisible === true && nextVisible === false) { + attemptGuardRef.current.cancel(); + + if (isRecordingRef.current) { + cancelRef.current?.(); + return; + } + + if (isProcessingRef.current) { + stopDurationTracking(); + setDuration(0); + setIsProcessing(false); + setVolume(0); + setError(null); + setStatus("idle"); + setRetryAttempt(0); + setRetryInfo(null); + pendingAudioRef.current = null; + setFailedRecording(null); + setLastOutcome(null); + } } - }, [autoStopWhenHidden?.isVisible]); + }, [autoStopWhenHidden?.isVisible, stopDurationTracking]); useEffect(() => { return () => { + attemptGuardRef.current.cancel(); stopDurationTracking(); const activeStop = activeStopPromiseRef.current; if (activeStop) { diff --git a/packages/app/src/utils/attempt-guard.ts b/packages/app/src/utils/attempt-guard.ts new file mode 100644 index 000000000..020346a5f --- /dev/null +++ b/packages/app/src/utils/attempt-guard.ts @@ -0,0 +1,36 @@ +export class AttemptCancelledError extends Error { + constructor(message = "Attempt cancelled") { + super(message); + this.name = "AttemptCancelledError"; + } +} + +/** + * Tiny helper for async lifecycles: + * - Call `next()` to start a new attempt and invalidate previous ones + * - Call `cancel()` to invalidate any in-flight attempt + * - Call `assertCurrent(id)` after awaits to enforce a single active attempt + */ +export class AttemptGuard { + private attemptId = 0; + + public next(): number { + this.attemptId += 1; + return this.attemptId; + } + + public cancel(): void { + this.attemptId += 1; + } + + public isCurrent(id: number): boolean { + return id === this.attemptId; + } + + public assertCurrent(id: number): void { + if (!this.isCurrent(id)) { + throw new AttemptCancelledError(); + } + } +} +