From e1bde99ce473c9d89ba180a15c5ed477b19951ba Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 16 Jul 2026 23:26:48 -0500 Subject: [PATCH 1/2] Fix pasteback confirmation and retry feedback --- Resources/analytics-events.psv | 1 + .../DictationPasteRetryTelemetry.swift | 51 ++ .../ClipboardRestoringTextPaster.swift | 140 +++++- .../Overlay/DictationSessionController.swift | 123 ++++- .../Overlay/FloatingOverlayController.swift | 27 +- Tests/AnalyticsEventPolicyTests.swift | 23 + Tests/ClipboardRestoringTextPasterTests.swift | 454 ++++++++++++++++++ docs/privacy-first-observability.md | 3 + scripts/entrypoints/run-tests.sh | 1 + 9 files changed, 777 insertions(+), 46 deletions(-) create mode 100644 Sources/Observability/DictationPasteRetryTelemetry.swift diff --git a/Resources/analytics-events.psv b/Resources/analytics-events.psv index c56ea713c..e6f155399 100644 --- a/Resources/analytics-events.psv +++ b/Resources/analytics-events.psv @@ -28,6 +28,7 @@ dictation_audio_route_recovery_timeout|default_input_class,default_output_class, dictation_cancelled|default_input_class,default_output_class,duration_bucket,format_ready,hfp_suspected,input_channels,input_device_class,input_rate_hz,output_channels,output_device_class,output_rate_hz,recovering,recovery_latency_bucket,route_shape,sample_flow_started,selected_input_class,selection_overrode_default,selection_reason,trigger,was_recording dictation_completed|auto_send,auto_send_block_reason,auto_send_expected,auto_send_key,default_input_class,default_output_class,delivery,duration_bucket,format_ready,hfp_suspected,input_channels,input_device_class,input_rate_hz,output_channels,output_device_class,output_rate_hz,recovering,recovery_latency_bucket,route_shape,sample_flow_started,selected_input_class,selection_overrode_default,selection_reason,target_confirmation_mode,trigger,was_recording,word_count_bucket dictation_no_speech|default_input_class,default_output_class,duration_bucket,format_ready,hfp_suspected,input_channels,input_device_class,input_rate_hz,output_channels,output_device_class,output_rate_hz,recovering,recovery_latency_bucket,route_shape,sample_flow_started,selected_input_class,selection_overrode_default,selection_reason,trigger,was_recording +dictation_paste_retry_completed|reason,result dictation_recording_too_short|default_input_class,default_output_class,duration_bucket,format_ready,hfp_suspected,input_channels,input_device_class,input_rate_hz,output_channels,output_device_class,output_rate_hz,recovering,recovery_latency_bucket,route_shape,sample_flow_started,selected_input_class,selection_overrode_default,selection_reason,trigger,was_recording dictation_start_failed|default_input_class,default_output_class,failure_kind,format_ready,hfp_suspected,input_channels,input_device_class,input_rate_hz,output_channels,output_device_class,output_rate_hz,recovering,recovery_latency_bucket,route_shape,sample_flow_started,selected_input_class,selection_overrode_default,selection_reason,start_attempt_bucket,trigger,was_recording dictation_started|default_input_class,default_output_class,format_ready,hfp_suspected,input_channels,input_device_class,input_rate_hz,output_channels,output_device_class,output_rate_hz,recovering,recovery_latency_bucket,route_shape,sample_flow_started,selected_input_class,selection_overrode_default,selection_reason,trigger,was_recording diff --git a/Sources/Observability/DictationPasteRetryTelemetry.swift b/Sources/Observability/DictationPasteRetryTelemetry.swift new file mode 100644 index 000000000..74f916cbe --- /dev/null +++ b/Sources/Observability/DictationPasteRetryTelemetry.swift @@ -0,0 +1,51 @@ +import Foundation + +enum DictationPasteRetryTelemetry { + static let eventName = "dictation_paste_retry_completed" + + @discardableResult + static func performUserRetry( + track: (String, [String: String]) -> Void = { event, properties in + AnalyticsReporter.track(event, properties: properties) + }, + retry: () -> TextPasteOutcome + ) -> TextPasteOutcome { + let outcome = retry() + track(eventName, properties(for: outcome)) + return outcome + } + + private static func properties(for outcome: TextPasteOutcome) -> [String: String] { + switch outcome { + case .pasted: + return ["result": "pasted"] + case .copied(_, reason: let reason): + return [ + "reason": reason.analyticsName, + "result": "copied", + ] + case .failed: + return [ + "reason": "clipboard_unavailable", + "result": "failed", + ] + } + } +} + +private extension TextPasteCopyReason { + var analyticsName: String { + switch self { + case .accessibilityMissing: + return "accessibility_missing" + case .pasteEventCreationFailed: + return "paste_event_creation_failed" + case .focusChanged: + return "focus_changed" + case .pasteNotConfirmed: + return "paste_not_confirmed" + case .pasteConfirmationUnavailable, .pasteConfirmationUnavailableAutoSendEligible: + return "confirmation_unavailable" + } + } +} diff --git a/Sources/Support/ClipboardRestoringTextPaster.swift b/Sources/Support/ClipboardRestoringTextPaster.swift index 2f7f57106..6c7378f83 100644 --- a/Sources/Support/ClipboardRestoringTextPaster.swift +++ b/Sources/Support/ClipboardRestoringTextPaster.swift @@ -56,6 +56,12 @@ struct ClipboardPasteConfirmationDiagnostic: Equatable { let context: [String: String] } +private enum ClipboardPasteConfirmationWaitResult: Equatable { + case confirmed + case unconfirmed + case focusChanged +} + enum DictationTargetConfirmationMode: String, Equatable { case textValue = "text_value" case selectionRange = "selection_range" @@ -560,6 +566,7 @@ final class ClipboardRestoringTextPaster { private var clipboardAutoEnterReadinessTask: Task? private var clipboardAutoEnterReadyGeneration: Int? private var pendingClipboardRestore: PendingClipboardRestore? + private var retainedClipboardRestoreForPasteRetry: PendingClipboardRestore? private var temporaryPasteboardDataProvider: TemporaryPasteboardStringProvider? private var pasteGeneration = 0 private(set) var lastConfirmationDiagnostic: ClipboardPasteConfirmationDiagnostic? @@ -570,9 +577,14 @@ final class ClipboardRestoringTextPaster { } func cancelPendingClipboardRestore() { + discardPasteRetry() restorePendingClipboardNow() } + func discardPasteRetry() { + retainedClipboardRestoreForPasteRetry = nil + } + func restorePendingClipboardNow() { guard let pendingClipboardRestore else { clearPendingClipboardRestore(restore: false) @@ -604,6 +616,45 @@ final class ClipboardRestoringTextPaster { await waitForPendingClipboardRestore() } + /// Retries only the paste gesture. It restores the clipboard snapshot retained by a + /// genuinely unconfirmed first attempt, then borrows the clipboard again. Auto Enter + /// is intentionally not part of this API, so an explicit retry cannot submit twice. + func retryPaste( + _ text: String, + target: DictationPasteTarget? = nil, + activationWait: TimeInterval = TranscriptedConstants.clipboardTargetActivationWait, + pasteboard: any ClipboardPasteboard = NSPasteboard.general, + accessibilityTrusted: () -> Bool = { AXIsProcessTrusted() }, + requestAccessibilityTrust: () -> Void = { + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) + }, + pasteDispatcher: @MainActor () -> Bool = postClipboardPasteShortcut, + pasteConfirmed: (@MainActor () -> Bool)? = nil, + targetIsFrontmost: (@MainActor () -> Bool)? = nil, + restoreDelay: UInt64 = TranscriptedConstants.clipboardRestoreDelay, + fallbackRestoreDelay: UInt64 = TranscriptedConstants.clipboardRestoreFallbackDelay, + pasteConfirmationWait: TimeInterval = TranscriptedConstants.clipboardPasteConfirmationWait + ) -> TextPasteOutcome { + restoreRetainedClipboardBeforePasteRetry(text: text, pasteboard: pasteboard) + return paste( + text, + target: target, + activationWait: activationWait, + pasteboard: pasteboard, + accessibilityTrusted: accessibilityTrusted, + requestAccessibilityTrust: requestAccessibilityTrust, + pasteDispatcher: pasteDispatcher, + pasteConfirmed: pasteConfirmed, + targetIsFrontmost: targetIsFrontmost, + prepareForAutoSend: false, + retainClipboardForPasteRetry: false, + restoreDelay: restoreDelay, + fallbackRestoreDelay: fallbackRestoreDelay, + pasteConfirmationWait: pasteConfirmationWait + ) + } + func paste( _ text: String, target: DictationPasteTarget? = nil, @@ -616,12 +667,15 @@ final class ClipboardRestoringTextPaster { }, pasteDispatcher: @MainActor () -> Bool = postClipboardPasteShortcut, pasteConfirmed: (@MainActor () -> Bool)? = nil, + targetIsFrontmost: (@MainActor () -> Bool)? = nil, prepareForAutoSend: Bool = false, + retainClipboardForPasteRetry: Bool = true, restoreDelay: UInt64 = TranscriptedConstants.clipboardRestoreDelay, fallbackRestoreDelay: UInt64 = TranscriptedConstants.clipboardRestoreFallbackDelay, pasteConfirmationWait: TimeInterval = TranscriptedConstants.clipboardPasteConfirmationWait ) -> TextPasteOutcome { lastConfirmationDiagnostic = nil + discardPasteRetry() restorePendingClipboardBeforeNewPaste() if let target, @@ -686,6 +740,9 @@ final class ClipboardRestoringTextPaster { } let confirmationUnavailable = pasteConfirmed == nil && accessibilityConfirmation?.canObservePaste != true + let targetRemainsFrontmost = targetIsFrontmost ?? { + target?.matchesCurrentFrontmostApp() != false + } let confirmPasteReceived = pasteConfirmed ?? { if accessibilityConfirmation?.confirmationMode( text, @@ -698,12 +755,12 @@ final class ClipboardRestoringTextPaster { return false } - let pasteWasConfirmed = waitForPasteConfirmation( - target: target, + let pasteConfirmationResult = waitForPasteConfirmation( + targetIsFrontmost: targetRemainsFrontmost, pasteConfirmed: confirmPasteReceived, timeout: pasteConfirmationWait ) - guard pasteWasConfirmed else { + guard pasteConfirmationResult == .confirmed else { var diagnostics = accessibilityConfirmation?.diagnosticsContext( clipboardReadAt: temporaryProvider?.firstReadAt, pasteDispatchedAt: pasteDispatchedAt @@ -714,17 +771,26 @@ final class ClipboardRestoringTextPaster { "target_selection_observable": "false", "target_text_observable": "false", ] - diagnostics["target_still_frontmost"] = "\(target?.matchesCurrentFrontmostApp() != false)" + let targetStillFrontmost = pasteConfirmationResult == .unconfirmed + diagnostics["target_still_frontmost"] = "\(targetStillFrontmost)" lastConfirmationDiagnostic = ClipboardPasteConfirmationDiagnostic( event: "dictation_paste_confirmation_diagnostics", context: diagnostics ) let clipboardReadAfterDispatch = (temporaryProvider?.firstReadAt ?? 0) >= pasteDispatchedAt - let targetStillFrontmost = target != nil && target?.matchesCurrentFrontmostApp() == true + if !targetStillFrontmost { + guard leaveTemporaryClipboardAvailable() else { + return .failed("Couldn't keep the dictation copied after focus moved. The dictation was saved, but paste-back did not run.") + } + return .copied( + "Focus moved before Transcripted could confirm paste. The text is on your clipboard — press ⌘V.", + reason: .focusChanged + ) + } if confirmationUnavailable, prepareForAutoSend, clipboardReadAfterDispatch, - targetStillFrontmost { + target != nil { scheduleClipboardRestore( savedItems, temporaryString: text, @@ -738,7 +804,9 @@ final class ClipboardRestoringTextPaster { reason: .pasteConfirmationUnavailableAutoSendEligible ) } - guard leaveTemporaryClipboardAvailable() else { + guard leaveTemporaryClipboardAvailable( + retainingRestoreForPasteRetry: !confirmationUnavailable && retainClipboardForPasteRetry + ) else { return .failed("Couldn't keep the dictation copied after paste-back was unconfirmed. The dictation was saved, but paste-back did not run.") } if confirmationUnavailable { @@ -816,7 +884,9 @@ final class ClipboardRestoringTextPaster { ) } - private func leaveTemporaryClipboardAvailable() -> Bool { + private func leaveTemporaryClipboardAvailable( + retainingRestoreForPasteRetry: Bool = false + ) -> Bool { guard let pendingClipboardRestore else { clearPendingClipboardRestore(restore: false) return true @@ -833,7 +903,39 @@ final class ClipboardRestoringTextPaster { if pasteboard.changeCount != temporaryChangeCount { return true } - return copyTextToClipboard(temporaryString, to: pasteboard) + guard copyTextToClipboard(temporaryString, to: pasteboard) else { + return false + } + if retainingRestoreForPasteRetry { + retainedClipboardRestoreForPasteRetry = PendingClipboardRestore( + savedItems: pendingClipboardRestore.savedItems, + temporaryString: temporaryString, + temporaryChangeCount: pasteboard.changeCount, + pasteboard: pasteboard, + generation: pasteGeneration + ) + } + return true + } + + private func restoreRetainedClipboardBeforePasteRetry( + text: String, + pasteboard: any ClipboardPasteboard + ) { + guard let retained = retainedClipboardRestoreForPasteRetry else { return } + retainedClipboardRestoreForPasteRetry = nil + guard retained.temporaryString == text, + (retained.pasteboard as AnyObject) === (pasteboard as AnyObject), + pasteboard.changeCount == retained.temporaryChangeCount, + pasteboard.string(forType: .string) == text else { + return + } + restorePasteboardItems( + retained.savedItems, + temporaryString: retained.temporaryString, + temporaryChangeCount: retained.temporaryChangeCount, + to: pasteboard + ) } private func scheduleClipboardAutoEnterReadiness(generation: Int, delay: UInt64) { @@ -943,25 +1045,29 @@ final class ClipboardRestoringTextPaster { } private func waitForPasteConfirmation( - target: DictationPasteTarget?, + targetIsFrontmost: @MainActor () -> Bool, pasteConfirmed: @MainActor () -> Bool, timeout: TimeInterval - ) -> Bool { - guard target?.matchesCurrentFrontmostApp() != false else { return false } + ) -> ClipboardPasteConfirmationWaitResult { + guard targetIsFrontmost() else { return .focusChanged } if pasteConfirmed() { - return true + return targetIsFrontmost() ? .confirmed : .focusChanged } - guard timeout > 0 else { return false } + guard targetIsFrontmost() else { return .focusChanged } + guard timeout > 0 else { return .unconfirmed } let start = Date() while Date().timeIntervalSince(start) < timeout { _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.02)) - guard target?.matchesCurrentFrontmostApp() != false else { return false } + guard targetIsFrontmost() else { return .focusChanged } if pasteConfirmed() { - return true + return targetIsFrontmost() ? .confirmed : .focusChanged } } - return pasteConfirmed() + guard targetIsFrontmost() else { return .focusChanged } + let confirmed = pasteConfirmed() + guard targetIsFrontmost() else { return .focusChanged } + return confirmed ? .confirmed : .unconfirmed } func snapshotPasteboardItems(from pasteboard: any ClipboardPasteboard) -> PasteboardSnapshot { diff --git a/Sources/UI/Overlay/DictationSessionController.swift b/Sources/UI/Overlay/DictationSessionController.swift index 6678f15bb..f53b3f271 100644 --- a/Sources/UI/Overlay/DictationSessionController.swift +++ b/Sources/UI/Overlay/DictationSessionController.swift @@ -30,6 +30,8 @@ class DictationSessionController: ObservableObject { } var overlayController: FloatingOverlayController? { didSet { + oldValue?.onActionableMessageDiscarded = nil + textPaster.discardPasteRetry() overlayController?.onEscapeDuringSession = { [weak self] in guard let self else { return } guard self.isDictating else { @@ -42,6 +44,9 @@ class DictationSessionController: ObservableObject { guard let self = self, self.isDictating else { return } self.stopDictationAndPaste(trigger: .overlayButton) } + overlayController?.onActionableMessageDiscarded = { [weak self] in + self?.textPaster.discardPasteRetry() + } } } @@ -1100,10 +1105,9 @@ class DictationSessionController: ObservableObject { let saveFailureMessage = saveResult.failureMessage let wordCount = text.split(whereSeparator: \.isWhitespace).count stopTiming.completedAt = CFAbsoluteTimeGetCurrent() - let deliveryLevel: EventLevel = pasteOutcome.delivery == .pasted ? .info : .warning DiagnosticsTrail.record( logger: appState.logger, - level: deliveryLevel, + level: pasteOutcome.diagnosticLevel, engine: "dictation", event: "dictation_delivery_completed", message: pasteOutcome.diagnosticMessage, @@ -1148,14 +1152,26 @@ class DictationSessionController: ObservableObject { } else { overlayController.showSuccessAndDismiss(title: autoSendOutcome.confirmationTitle ?? "Pasted") } - case .copied(let message, reason: let reason) where reason.isPasteConfirmationOnly: + case .copied(let message, reason: let reason) where reason.isPasteConfirmationUnavailable: AppSoundPlayer.shared.play(.dictationDelivered) if let saveFailureMessage { overlayController.showError(saveFailureMessage) } else { - overlayController.showSuccessAndDismiss(title: autoSendOutcome.confirmationTitle ?? "Pasted") + overlayController.showSuccessAndDismiss(title: autoSendOutcome.confirmationTitle ?? "Paste sent") } - appState.logger.log("DICTATION | paste confirmation missing after Cmd+V; suppressing user warning: \(message)") + appState.logger.log("DICTATION | target does not expose paste confirmation; showing neutral delivery feedback: \(message)") + case .copied(let message, reason: .pasteNotConfirmed): + let visibleMessage = saveFailureMessage.map { "\(message) \($0)" } ?? message + overlayController.showError( + visibleMessage, + actionTitle: "Paste Again", + action: { [weak self] in + self?.retryPasteWithoutAutoEnter( + text, + saveFailureMessage: saveFailureMessage + ) + } + ) case .copied(let message, reason: _): if let saveFailureMessage { overlayController.showError("\(message) \(saveFailureMessage)") @@ -1764,46 +1780,105 @@ class DictationSessionController: ObservableObject { target: sessionPasteTarget, prepareForAutoSend: autoSendRequestDecision.expected ) + recordPasteAttemptOutcome(outcome, attempt: "initial") + return outcome + } + + private func retryPasteWithoutAutoEnter( + _ text: String, + saveFailureMessage: String? + ) { + guard let overlayController else { return } + retargetPasteToCurrentFocus() + let outcome = DictationPasteRetryTelemetry.performUserRetry { + textPaster.retryPaste( + text, + target: sessionPasteTarget + ) + } + recordPasteAttemptOutcome(outcome, attempt: "retry") + + switch outcome { + case .pasted: + AppSoundPlayer.shared.play(.dictationDelivered) + if let saveFailureMessage { + overlayController.showError(saveFailureMessage) + } else { + overlayController.showSuccessAndDismiss(title: "Pasted") + } + case .copied(let message, reason: let reason) where reason.isPasteConfirmationUnavailable: + AppSoundPlayer.shared.play(.dictationDelivered) + if let saveFailureMessage { + overlayController.showError(saveFailureMessage) + } else { + overlayController.showSuccessAndDismiss(title: "Paste sent") + } + appState?.logger.log("DICTATION | Paste Again sent; target still does not expose confirmation: \(message)") + case .copied(let message, reason: .pasteNotConfirmed): + let retryMessage = "Still couldn't confirm the paste. The text is copied — press ⌘V." + overlayController.showError( + saveFailureMessage.map { "\(retryMessage) \($0)" } ?? retryMessage + ) + appState?.logger.log("DICTATION | Paste Again remained unconfirmed: \(message)") + case .copied(let message, reason: _): + if let saveFailureMessage { + overlayController.showError("\(message) \(saveFailureMessage)") + } else { + overlayController.showClipboardNotice(message) + } + case .failed(let message): + overlayController.showError( + saveFailureMessage.map { "\(message) \($0)" } ?? message + ) + } + appState?.logger.log("DICTATION | Paste Again completed with outcome \(outcome)") + } + + private func recordPasteAttemptOutcome( + _ outcome: DictationPasteOutcome, + attempt: String + ) { if let diagnostic = textPaster.lastConfirmationDiagnostic { + var context = diagnostic.context + context["attempt"] = attempt EventReporter.shared.capture( - level: diagnostic.event == "dictation_paste_confirmed" ? .info : .warning, + level: diagnostic.event == "dictation_paste_confirmed" ? .info : outcome.diagnosticLevel, engine: "overlay", event: diagnostic.event, message: diagnostic.event == "dictation_paste_confirmed" ? "Paste delivery confirmed from privacy-safe target signals" : "Paste delivery could not be confirmed from privacy-safe target signals", - context: diagnostic.context + context: context ) } + let context = ["attempt": attempt] switch outcome.copyReason { case .accessibilityMissing: appState?.logger.log("DICTATION | Accessibility missing, copying text instead") case .pasteEventCreationFailed: EventReporter.shared.capture(level: .error, engine: "overlay", event: "cgevent_create_failed", - message: "CGEvent creation returned nil — paste will not work") + message: "CGEvent creation returned nil — paste will not work", context: context) appState?.logger.log("DICTATION | CGEvent paste failed, keeping text on clipboard") case .focusChanged: EventReporter.shared.capture(level: .warning, engine: "overlay", event: "dictation_paste_target_changed", - message: "Focus changed before dictation paste") + message: "Focus changed before dictation paste", context: context) appState?.logger.log("DICTATION | focus changed, copying text instead") case .pasteNotConfirmed: EventReporter.shared.capture(level: .warning, engine: "overlay", event: "dictation_paste_not_confirmed", - message: "Paste-back was dispatched but the target did not confirm reading the borrowed clipboard") + message: "Paste-back was dispatched but the target did not confirm reading the borrowed clipboard", context: context) appState?.logger.log("DICTATION | paste not confirmed, keeping text on clipboard") case .pasteConfirmationUnavailable: - EventReporter.shared.capture(level: .warning, engine: "overlay", event: "dictation_paste_confirmation_unavailable", - message: "Paste-back was dispatched but the target did not expose confirmation") + EventReporter.shared.capture(level: .info, engine: "overlay", event: "dictation_paste_confirmation_unavailable", + message: "Paste-back was dispatched but the target did not expose confirmation", context: context) appState?.logger.log("DICTATION | paste confirmation unavailable, keeping text on clipboard") case .pasteConfirmationUnavailableAutoSendEligible: EventReporter.shared.capture(level: .info, engine: "overlay", event: "dictation_paste_confirmation_unavailable", - message: "Selected Auto Enter target read paste-back but did not expose text confirmation") + message: "Selected Auto Enter target read paste-back but did not expose text confirmation", context: context) appState?.logger.log("DICTATION | selected Auto Enter target read paste; restoring clipboard before follow-up key") case nil: break } - - return outcome } private func retargetPasteToCurrentFocus() { @@ -1986,7 +2061,8 @@ class DictationSessionController: ObservableObject { routeShape: dictationAnalyticsProperties()["route_shape"], modelState: ProductFrictionTelemetry.modelState(isReady: appState?.sttRouter.isModelLoaded) ) - } else if let copyReason = pasteOutcome.copyReason { + } else if let copyReason = pasteOutcome.copyReason, + !copyReason.isPasteConfirmationUnavailable { ProductFrictionTelemetry.track( surface: .dictation, stage: "pasteback", @@ -2057,7 +2133,7 @@ class DictationSessionController: ObservableObject { DiagnosticsTrail.record( logger: appState.logger, - level: pasteOutcome.delivery == .pasted && saveSucceeded ? .info : .warning, + level: saveSucceeded ? pasteOutcome.diagnosticLevel : .warning, engine: "dictation", event: "dictation_stop_latency_measured", message: "Measured dictation stop latency", @@ -2264,10 +2340,21 @@ private extension TextPasteOutcome { return .failed } } + + var diagnosticLevel: EventLevel { + switch self { + case .pasted: + return .info + case .copied(_, reason: let reason) where reason.isPasteConfirmationUnavailable: + return .info + case .copied, .failed: + return .warning + } + } } private extension TextPasteCopyReason { - var isPasteConfirmationOnly: Bool { + var isPasteConfirmationUnavailable: Bool { switch self { case .pasteConfirmationUnavailable, .pasteConfirmationUnavailableAutoSendEligible: return true diff --git a/Sources/UI/Overlay/FloatingOverlayController.swift b/Sources/UI/Overlay/FloatingOverlayController.swift index bc72e1c3e..662a7db6f 100644 --- a/Sources/UI/Overlay/FloatingOverlayController.swift +++ b/Sources/UI/Overlay/FloatingOverlayController.swift @@ -96,6 +96,7 @@ class FloatingOverlayController { /// Closure for Escape during active dictation overlay states. var onEscapeDuringSession: (() -> Void)? var onStopListening: (() -> Void)? + var onActionableMessageDiscarded: (() -> Void)? // MARK: - Panel & Views @@ -399,8 +400,7 @@ class FloatingOverlayController { cancelMiniLoadingReveal() errorMessage = "" messageTone = .error - errorActionTitle = nil - errorActionHandler = nil + discardActionableMessageIfNeeded() listeningNotice = "" state = .starting resizePanelToCompact() @@ -530,8 +530,7 @@ class FloatingOverlayController { errorDismissTask?.cancel() errorMessage = "" messageTone = .error - errorActionTitle = nil - errorActionHandler = nil + discardActionableMessageIfNeeded() if let presentation { loadingPresentation = presentation } @@ -626,6 +625,7 @@ class FloatingOverlayController { errorDismissTask?.cancel() loadingTimerTask?.cancel() loadingTimerTask = nil + discardActionableMessageIfNeeded() errorMessage = message messageTone = tone errorActionTitle = actionTitle @@ -654,9 +654,17 @@ class FloatingOverlayController { errorDismissTask?.cancel() errorDismissTask = nil errorMessage = "" + discardActionableMessageIfNeeded() + hideWithCancelAnimation() + } + + private func discardActionableMessageIfNeeded() { + let hadAction = errorActionHandler != nil errorActionTitle = nil errorActionHandler = nil - hideWithCancelAnimation() + if hadAction { + onActionableMessageDiscarded?() + } } private func clearActionableErrorWithoutHiding() { @@ -677,8 +685,7 @@ class FloatingOverlayController { errorDismissTask?.cancel() errorMessage = DictationNoSpeechPresentationPolicy.message(trigger: trigger, reason: reason) messageTone = .error - errorActionTitle = nil - errorActionHandler = nil + discardActionableMessageIfNeeded() state = .drafting resizePanel(to: NSSize(width: OverlayTokens.panelWidth, height: OverlayTokens.panelMinHeight)) if !isVisible { @@ -701,8 +708,7 @@ class FloatingOverlayController { successDismissTask?.cancel() errorMessage = "" messageTone = .error - errorActionTitle = nil - errorActionHandler = nil + discardActionableMessageIfNeeded() successTitle = title state = .success resizePanelToCompact() @@ -743,8 +749,7 @@ class FloatingOverlayController { state = .idle errorMessage = "" messageTone = .error - errorActionTitle = nil - errorActionHandler = nil + discardActionableMessageIfNeeded() listeningNotice = "" loadingPresentation = .initial } diff --git a/Tests/AnalyticsEventPolicyTests.swift b/Tests/AnalyticsEventPolicyTests.swift index c52393511..e123cfe68 100644 --- a/Tests/AnalyticsEventPolicyTests.swift +++ b/Tests/AnalyticsEventPolicyTests.swift @@ -1014,6 +1014,29 @@ func testAnalyticsEventPolicy() { assertEqual(dictationCompleted?.allowedProperties.contains("sample_flow_started"), true, "dictation completion should preserve whether audio samples ever flowed") } + runSuite("AnalyticsEventPolicy allows one coarse terminal paste retry event") { + let retryCompleted = AnalyticsEventPolicy.policy(forEvent: "dictation_paste_retry_completed") + + assertEqual( + retryCompleted?.allowedProperties ?? [], + ["reason", "result"], + "paste retry telemetry should expose only its terminal result and coarse reason" + ) + + let sanitized = AnalyticsPayloadSanitizer.sanitizeProperties( + [ + "capture_id": "private-capture-id", + "message": "private retry details", + "reason": "focus_changed", + "result": "copied", + "source_app_bundle": "com.example.PrivateApp", + "transcript": "private dictated words", + ], + allowedKeys: retryCompleted?.allowedProperties ?? [] + ) + assertEqual(sanitized, ["reason": "focus_changed", "result": "copied"], "retry analytics should remain aggregate-only") + } + runSuite("AnalyticsEventPolicy allows dictation start failures with coarse attribution") { let dictationStartFailed = AnalyticsEventPolicy.policy(forEvent: "dictation_start_failed") diff --git a/Tests/ClipboardRestoringTextPasterTests.swift b/Tests/ClipboardRestoringTextPasterTests.swift index b49a78de3..fe51869cb 100644 --- a/Tests/ClipboardRestoringTextPasterTests.swift +++ b/Tests/ClipboardRestoringTextPasterTests.swift @@ -83,6 +83,146 @@ func testClipboardRestoringTextPaster() async { ) } + runSuite("DictationSessionController — unconfirmed paste retry stays paste-only") { + let source = try! String( + contentsOfFile: "Sources/UI/Overlay/DictationSessionController.swift", + encoding: .utf8 + ) + let pasterSource = try! String( + contentsOfFile: "Sources/Support/ClipboardRestoringTextPaster.swift", + encoding: .utf8 + ) + let overlaySource = try! String( + contentsOfFile: "Sources/UI/Overlay/FloatingOverlayController.swift", + encoding: .utf8 + ) + assertTrue( + source.contains("case .copied(let message, reason: .pasteNotConfirmed):") + && source.contains("actionTitle: \"Paste Again\"") + && source.contains("showSuccessAndDismiss(title: autoSendOutcome.confirmationTitle ?? \"Paste sent\")"), + "observable failures should offer Paste Again while confirmation-unavailable targets stay neutral" + ) + + let retryStart = source.range(of: "private func retryPasteWithoutAutoEnter(") + let retryEnd = retryStart.flatMap { start in + source.range( + of: "private func recordPasteAttemptOutcome(", + range: start.upperBound.. Date: Fri, 17 Jul 2026 06:12:24 -0500 Subject: [PATCH 2/2] Restore clipboard when retry is cancelled --- .../ClipboardRestoringTextPaster.swift | 18 ++++++++- Tests/ClipboardRestoringTextPasterTests.swift | 40 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/Sources/Support/ClipboardRestoringTextPaster.swift b/Sources/Support/ClipboardRestoringTextPaster.swift index 6c7378f83..fd1cfd49b 100644 --- a/Sources/Support/ClipboardRestoringTextPaster.swift +++ b/Sources/Support/ClipboardRestoringTextPaster.swift @@ -577,8 +577,8 @@ final class ClipboardRestoringTextPaster { } func cancelPendingClipboardRestore() { - discardPasteRetry() restorePendingClipboardNow() + restoreRetainedClipboardNow() } func discardPasteRetry() { @@ -938,6 +938,22 @@ final class ClipboardRestoringTextPaster { ) } + private func restoreRetainedClipboardNow() { + guard let retained = retainedClipboardRestoreForPasteRetry else { return } + retainedClipboardRestoreForPasteRetry = nil + let pasteboard = retained.pasteboard + guard pasteboard.changeCount == retained.temporaryChangeCount, + pasteboard.string(forType: .string) == retained.temporaryString else { + return + } + restorePasteboardItems( + retained.savedItems, + temporaryString: retained.temporaryString, + temporaryChangeCount: retained.temporaryChangeCount, + to: pasteboard + ) + } + private func scheduleClipboardAutoEnterReadiness(generation: Int, delay: UInt64) { guard generation == pasteGeneration, clipboardAutoEnterReadyGeneration != generation else { return } diff --git a/Tests/ClipboardRestoringTextPasterTests.swift b/Tests/ClipboardRestoringTextPasterTests.swift index fe51869cb..eb3b0d3b4 100644 --- a/Tests/ClipboardRestoringTextPasterTests.swift +++ b/Tests/ClipboardRestoringTextPasterTests.swift @@ -1686,6 +1686,46 @@ func testClipboardRestoringTextPaster() async { ) } + await runSuite("ClipboardRestoringTextPaster.cancelPendingClipboardRestore — restores retry-retained clipboard") { + let existingClipboard = "synthetic existing clipboard" + let pasteText = "synthetic unconfirmed paste" + let pasteboard = await MainActor.run { + FakeClipboardPasteboard(initialString: existingClipboard) + } + let paster = await MainActor.run { ClipboardRestoringTextPaster() } + + let outcome = await MainActor.run { + let outcome = paster.paste( + pasteText, + pasteboard: pasteboard, + accessibilityTrusted: { true }, + requestAccessibilityTrust: {}, + pasteDispatcher: { true }, + pasteConfirmed: { false }, + pasteConfirmationWait: 0 + ) + paster.cancelPendingClipboardRestore() + return outcome + } + + assertEqual( + outcome, + .copied( + "Transcripted tried to paste, but could not confirm the target received it. The text stays copied.", + reason: .pasteNotConfirmed + ), + "the first paste should retain its restore snapshot for Paste Again" + ) + let clipboardAfterCancel = await MainActor.run { + pasteboard.string(forType: .string) + } + assertEqual( + clipboardAfterCancel, + existingClipboard, + "canceling should restore the user's clipboard even after the snapshot moved into retry retention" + ) + } + await runSuite("ClipboardRestoringTextPaster.paste — paste dispatcher failure cancels restore") { let pasteText = "synthetic paste fallback" let pasteboard = await MainActor.run {