From b05c4908efebbe6008011a5a328257610817c7ac Mon Sep 17 00:00:00 2001 From: Joey Jooste Date: Fri, 31 Jul 2026 07:41:58 +0100 Subject: [PATCH 1/4] Defer update prompts during dictation --- Sources/Fluid/AppDelegate.swift | 14 ++++++++++++++ Sources/Fluid/Services/ASRService.swift | 3 +++ Sources/Fluid/Services/AppServices.swift | 4 ++++ 3 files changed, 21 insertions(+) diff --git a/Sources/Fluid/AppDelegate.swift b/Sources/Fluid/AppDelegate.swift index 82e9bf45..3123b867 100644 --- a/Sources/Fluid/AppDelegate.swift +++ b/Sources/Fluid/AppDelegate.swift @@ -459,6 +459,20 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele @MainActor private func showUpdateNotification(version: String) { + guard !AppServices.shared.hasActiveDictation else { + DebugLogger.shared.info( + "Deferring update notification for \(version) until dictation finishes", + source: "AppDelegate" + ) + Task { @MainActor in + while AppServices.shared.hasActiveDictation { + try? await Task.sleep(nanoseconds: 500_000_000) + } + self.showUpdateNotification(version: version) + } + return + } + DebugLogger.shared.info("Showing update notification for version \(version)", source: "AppDelegate") let alert = NSAlert() diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index c0becd00..037df187 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -190,6 +190,7 @@ final class ASRService: ObservableObject { private(set) var dictionaryTrainingAudioGeneration = 0 @Published private(set) var isStarting: Bool = false // Guard against re-entrant start() calls + @Published private(set) var isFinalizing: Bool = false private var audioCaptureStartWaiters: [CheckedContinuation] = [] var isRunningOrStarting: Bool { self.isRunning || self.isStarting @@ -1797,8 +1798,10 @@ final class ASRService: ObservableObject { DebugLogger.shared.warning("⚠️ STOP() - not running, returning empty string", source: "ASRService") return "" } + self.isFinalizing = true let useDictionaryTrainingPath = forDictionaryTraining || self.isDictionaryTrainingCaptureActive defer { + self.isFinalizing = false self.applyPendingParakeetVocabularyReloadIfNeeded() self.isDictionaryTrainingCaptureActive = false } diff --git a/Sources/Fluid/Services/AppServices.swift b/Sources/Fluid/Services/AppServices.swift index bbcc7a08..dba6d8c9 100644 --- a/Sources/Fluid/Services/AppServices.swift +++ b/Sources/Fluid/Services/AppServices.swift @@ -68,6 +68,10 @@ final class AppServices: ObservableObject { return service } + var hasActiveDictation: Bool { + self._asr.map { $0.isRunningOrStarting || $0.isFinalizing } ?? false + } + private var _microphonePreferenceCoordinator: MicrophonePreferenceCoordinator? var microphonePreferenceCoordinator: MicrophonePreferenceCoordinator { if let existing = self._microphonePreferenceCoordinator { From 8e23a8b00040f3f1a9ddea7ae1b331de1bb0ca89 Mon Sep 17 00:00:00 2001 From: Joey Jooste Date: Fri, 31 Jul 2026 09:21:12 +0100 Subject: [PATCH 2/4] Keep update deferral active through output delivery --- Sources/Fluid/ContentView.swift | 3 +++ Sources/Fluid/Services/AppServices.swift | 8 +++++++- .../DictationE2ETests.swift | 8 ++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index db38b3a0..80d46a3a 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -2012,6 +2012,9 @@ struct ContentView: View { // MARK: - Stop and Process Transcription private func stopAndProcessTranscription(route: DictationOutputRoute = .normal) async { + self.appServices.setDictationOutputDeliveryActive(true) + defer { self.appServices.setDictationOutputDeliveryActive(false) } + DebugLogger.shared.debug("stopAndProcessTranscription called", source: "ContentView") DebugLogger.shared.info("Output route selected: \(route.rawValue)", source: "ContentView") self.appBench("stop_path_enter route=\(route.rawValue)") diff --git a/Sources/Fluid/Services/AppServices.swift b/Sources/Fluid/Services/AppServices.swift index dba6d8c9..4d90a242 100644 --- a/Sources/Fluid/Services/AppServices.swift +++ b/Sources/Fluid/Services/AppServices.swift @@ -57,6 +57,7 @@ final class AppServices: ObservableObject { /// Automatic speech recognition service (lazily initialized) private var _asr: ASRService? + private var isDeliveringDictationOutput = false var asr: ASRService { if let existing = self._asr { return existing @@ -69,7 +70,12 @@ final class AppServices: ObservableObject { } var hasActiveDictation: Bool { - self._asr.map { $0.isRunningOrStarting || $0.isFinalizing } ?? false + self.isDeliveringDictationOutput || + (self._asr.map { $0.isRunningOrStarting || $0.isFinalizing } ?? false) + } + + func setDictationOutputDeliveryActive(_ active: Bool) { + self.isDeliveringDictationOutput = active } private var _microphonePreferenceCoordinator: MicrophonePreferenceCoordinator? diff --git a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift index 2268de24..706d81a5 100644 --- a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift +++ b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift @@ -2051,6 +2051,14 @@ final class DictationE2ETests: XCTestCase { XCTAssertFalse(SimpleUpdater.isRollbackVersion(nil, differentFrom: "1.5.11-beta.3")) } + @MainActor + func testUpdatePromptDeferralCoversOutputDelivery() { + AppServices.shared.setDictationOutputDeliveryActive(true) + defer { AppServices.shared.setDictationOutputDeliveryActive(false) } + + XCTAssertTrue(AppServices.shared.hasActiveDictation) + } + // MARK: - Model download HTML/markup rejection (#353) func testLooksLikeHTML_rejectsMarkupVariants() { From 957d43c2424608133e197557a56513fc7c3e2c2e Mon Sep 17 00:00:00 2001 From: Joey Jooste Date: Fri, 31 Jul 2026 09:29:24 +0100 Subject: [PATCH 3/4] Wait for typing before ending update deferral --- Sources/Fluid/ContentView.swift | 19 +++++++++++-------- Sources/Fluid/Services/ASRService.swift | 6 ++++-- Sources/Fluid/Services/TypingService.swift | 7 ++++++- .../DictationE2ETests.swift | 12 ++++++++++++ 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 80d46a3a..e996735b 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -2381,20 +2381,23 @@ struct ContentView: View { if shouldTypeExternally { let typingTarget = self.resolveTypingTargetPID() - // Dispatch insertion as soon as the destination app is ready; the - // overlay hides asynchronously after output so it cannot delay paste. + // Insert as soon as the destination app is ready, then keep update + // prompts deferred until the background typing worker completes. if typingTarget.shouldRestoreOriginalFocus { await self.restoreFocusToRecordingTarget() } self.appBench( "text_ready_to_type_request elapsedMs=\(Int(((ProcessInfo.processInfo.systemUptime - finalTextReadyAt) * 1000).rounded()))" ) - self.asr.typeOutputPlanToActiveField( - finalOutputPlan, - preferredTargetPID: typingTarget.pid, - textReadyAt: finalTextReadyAt, - tracksDictionaryCorrections: true - ) + await withCheckedContinuation { continuation in + self.asr.typeOutputPlanToActiveField( + finalOutputPlan, + preferredTargetPID: typingTarget.pid, + textReadyAt: finalTextReadyAt, + tracksDictionaryCorrections: true, + completion: { continuation.resume() } + ) + } didTypeExternally = true if !shouldShowAIProcessingFailure, !didRequestOverlayHideOnStop { self.hideOverlayAfterOutput() diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 037df187..e836d7e1 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -4000,7 +4000,8 @@ final class ASRService: ObservableObject { _ plan: DictationLiteralOutputPlan, preferredTargetPID: pid_t?, textReadyAt: TimeInterval? = nil, - tracksDictionaryCorrections: Bool = false + tracksDictionaryCorrections: Bool = false, + completion: (() -> Void)? = nil ) { let requestedAt = ProcessInfo.processInfo.systemUptime let textReadyAge = textReadyAt.map { Int(((requestedAt - $0) * 1000).rounded()) } @@ -4014,7 +4015,8 @@ final class ASRService: ObservableObject { plan, preferredTargetPID: preferredTargetPID, textReadyAt: textReadyAt, - tracksDictionaryCorrections: tracksDictionaryCorrections + tracksDictionaryCorrections: tracksDictionaryCorrections, + completion: completion ) let dispatchedAt = ProcessInfo.processInfo.systemUptime let textReadyToDispatchMs = textReadyAt.map { diff --git a/Sources/Fluid/Services/TypingService.swift b/Sources/Fluid/Services/TypingService.swift index c88f557a..782db138 100644 --- a/Sources/Fluid/Services/TypingService.swift +++ b/Sources/Fluid/Services/TypingService.swift @@ -294,7 +294,8 @@ final class TypingService { _ plan: DictationLiteralOutputPlan, preferredTargetPID: pid_t?, textReadyAt: TimeInterval?, - tracksDictionaryCorrections: Bool = false + tracksDictionaryCorrections: Bool = false, + completion: (() -> Void)? = nil ) { let requestedAt = ProcessInfo.processInfo.systemUptime let text = plan.plainText @@ -315,6 +316,7 @@ final class TypingService { guard text.isEmpty == false else { self.bench("request_return reason=empty_text") self.log("[TypingService] ERROR: Empty text provided, aborting") + completion?() return } @@ -322,6 +324,7 @@ final class TypingService { guard !self.isCurrentlyTyping else { self.bench("request_return reason=already_typing") self.log("[TypingService] WARNING: Skipping text injection - already in progress") + completion?() return } @@ -330,6 +333,7 @@ final class TypingService { self.bench("request_return reason=accessibility_not_trusted") self.log("[TypingService] ERROR: Accessibility permissions required for text injection") self.log("[TypingService] Current accessibility status: \(AXIsProcessTrusted())") + completion?() return } @@ -347,6 +351,7 @@ final class TypingService { "complete totalMs=\(Self.elapsedMs(from: requestedAt, to: completedAt)) textReadyToCompleteMs=\(textReadyAt.map { String(Self.elapsedMs(from: $0, to: completedAt)) } ?? "nil")" ) self.log("[TypingService] Typing operation completed, isCurrentlyTyping set to false") + completion?() } self.log("[TypingService] Starting async text insertion process") diff --git a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift index 706d81a5..b8f8f329 100644 --- a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift +++ b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift @@ -2059,6 +2059,18 @@ final class DictationE2ETests: XCTestCase { XCTAssertTrue(AppServices.shared.hasActiveDictation) } + func testTypingCompletionRunsWhenNoOutputIsQueued() async { + let completed = self.expectation(description: "Typing completion") + TypingService().typeOutputPlanInstantly( + .plain(""), + preferredTargetPID: nil, + textReadyAt: nil, + completion: { completed.fulfill() } + ) + + await self.fulfillment(of: [completed], timeout: 1) + } + // MARK: - Model download HTML/markup rejection (#353) func testLooksLikeHTML_rejectsMarkupVariants() { From d5232e10c3b0fb96343d727682575c0876f7af8a Mon Sep 17 00:00:00 2001 From: Joey Jooste Date: Fri, 31 Jul 2026 09:50:05 +0100 Subject: [PATCH 4/4] Keep update deferral across dictation boundaries --- Sources/Fluid/ContentView.swift | 8 ++++++++ Sources/Fluid/Services/AppServices.swift | 12 +++++++++++- Sources/Fluid/Services/TypingService.swift | 16 +++++++++------- Sources/Fluid/Views/CommandModeView.swift | 6 +++++- Sources/Fluid/Views/RewriteModeView.swift | 6 +++++- .../DictationE2ETests.swift | 10 ++++++++++ 6 files changed, 48 insertions(+), 10 deletions(-) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index e996735b..a2871a2f 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -3110,7 +3110,9 @@ struct ContentView: View { ) } + self.appServices.beginDictationStartup() Task { + defer { self.appServices.endDictationStartup() } let startOutcome = await self.asr.start(onCaptureStarted: { if shouldPlayStartSound { TranscriptionSoundPlayer.shared.playStartSound() @@ -3367,7 +3369,9 @@ struct ContentView: View { "Starting voice recording for command", source: "ContentView" ) + self.appServices.beginDictationStartup() Task { + defer { self.appServices.endDictationStartup() } let startOutcome = await self.asr.start(onCaptureStarted: { TranscriptionSoundPlayer.shared.playStartSound() self.appBench("overlay_phase phase=recording trigger=first_pcm mode=command") @@ -3413,7 +3417,9 @@ struct ContentView: View { // Start recording immediately for the edit instruction DebugLogger.shared.info("Starting voice recording for edit mode", source: "ContentView") + self.appServices.beginDictationStartup() Task { + defer { self.appServices.endDictationStartup() } let startOutcome = await self.asr.start(onCaptureStarted: { TranscriptionSoundPlayer.shared.playStartSound() self.appBench("overlay_phase phase=recording trigger=first_pcm mode=edit") @@ -3758,7 +3764,9 @@ extension ContentView { self.appBench("overlay_mode_requested mode=Dictation") self.appBench("overlay_phase phase=connecting") } + self.appServices.beginDictationStartup() Task { + defer { self.appServices.endDictationStartup() } let asrStartStartedAt = ProcessInfo.processInfo.systemUptime DebugLogger.shared.benchmark("APP_BENCH", message: "asr_start_call", source: "AppBenchmark") let startOutcome = await self.asr.start(onCaptureStarted: { diff --git a/Sources/Fluid/Services/AppServices.swift b/Sources/Fluid/Services/AppServices.swift index 4d90a242..b65092b6 100644 --- a/Sources/Fluid/Services/AppServices.swift +++ b/Sources/Fluid/Services/AppServices.swift @@ -57,6 +57,7 @@ final class AppServices: ObservableObject { /// Automatic speech recognition service (lazily initialized) private var _asr: ASRService? + private var pendingDictationStartCount = 0 private var isDeliveringDictationOutput = false var asr: ASRService { if let existing = self._asr { @@ -70,10 +71,19 @@ final class AppServices: ObservableObject { } var hasActiveDictation: Bool { - self.isDeliveringDictationOutput || + self.pendingDictationStartCount > 0 || + self.isDeliveringDictationOutput || (self._asr.map { $0.isRunningOrStarting || $0.isFinalizing } ?? false) } + func beginDictationStartup() { + self.pendingDictationStartCount += 1 + } + + func endDictationStartup() { + self.pendingDictationStartCount = max(0, self.pendingDictationStartCount - 1) + } + func setDictationOutputDeliveryActive(_ active: Bool) { self.isDeliveringDictationOutput = active } diff --git a/Sources/Fluid/Services/TypingService.swift b/Sources/Fluid/Services/TypingService.swift index 782db138..1d6e98cb 100644 --- a/Sources/Fluid/Services/TypingService.swift +++ b/Sources/Fluid/Services/TypingService.swift @@ -345,13 +345,15 @@ final class TypingService { self.bench("worker_start queueDelayMs=\(Self.elapsedMs(from: requestedAt, to: workerStartedAt))") defer { - let completedAt = ProcessInfo.processInfo.systemUptime - self.isCurrentlyTyping = false - self.bench( - "complete totalMs=\(Self.elapsedMs(from: requestedAt, to: completedAt)) textReadyToCompleteMs=\(textReadyAt.map { String(Self.elapsedMs(from: $0, to: completedAt)) } ?? "nil")" - ) - self.log("[TypingService] Typing operation completed, isCurrentlyTyping set to false") - completion?() + Self.pasteboardRestoreQueue.async { + let completedAt = ProcessInfo.processInfo.systemUptime + self.isCurrentlyTyping = false + self.bench( + "complete totalMs=\(Self.elapsedMs(from: requestedAt, to: completedAt)) textReadyToCompleteMs=\(textReadyAt.map { String(Self.elapsedMs(from: $0, to: completedAt)) } ?? "nil")" + ) + self.log("[TypingService] Typing operation completed, isCurrentlyTyping set to false") + completion?() + } } self.log("[TypingService] Starting async text insertion process") diff --git a/Sources/Fluid/Views/CommandModeView.swift b/Sources/Fluid/Views/CommandModeView.swift index 1e7005ad..f32aabbb 100644 --- a/Sources/Fluid/Views/CommandModeView.swift +++ b/Sources/Fluid/Views/CommandModeView.swift @@ -593,7 +593,11 @@ struct CommandModeView: View { } } } else { - Task { await self.asr.start() } + self.appServices.beginDictationStartup() + Task { + defer { self.appServices.endDictationStartup() } + await self.asr.start() + } } } diff --git a/Sources/Fluid/Views/RewriteModeView.swift b/Sources/Fluid/Views/RewriteModeView.swift index 3f578ba4..e0ff2c8a 100644 --- a/Sources/Fluid/Views/RewriteModeView.swift +++ b/Sources/Fluid/Views/RewriteModeView.swift @@ -262,7 +262,11 @@ struct RewriteModeView: View { _ = self.asr.consumeLastCompletedAudioSnapshot() } } else { - Task { await self.asr.start() } + self.appServices.beginDictationStartup() + Task { + defer { self.appServices.endDictationStartup() } + await self.asr.start() + } } } diff --git a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift index b8f8f329..3a40b22b 100644 --- a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift +++ b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift @@ -2059,6 +2059,16 @@ final class DictationE2ETests: XCTestCase { XCTAssertTrue(AppServices.shared.hasActiveDictation) } + @MainActor + func testUpdatePromptDeferralCoversScheduledStartup() { + AppServices.shared.beginDictationStartup() + AppServices.shared.beginDictationStartup() + AppServices.shared.endDictationStartup() + defer { AppServices.shared.endDictationStartup() } + + XCTAssertTrue(AppServices.shared.hasActiveDictation) + } + func testTypingCompletionRunsWhenNoOutputIsQueued() async { let completed = self.expectation(description: "Typing completion") TypingService().typeOutputPlanInstantly(