From 586662968b4260f51df6771debcba032f3b7e2f2 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 17 Jul 2026 17:49:04 -0500 Subject: [PATCH 1/3] Repair pasteback confirmation and retry ownership --- Resources/analytics-events.psv | 1 + .../DictationPasteRetryTelemetry.swift | 51 +++ .../ClipboardRestoringTextPaster.swift | 180 ++++++++- .../Overlay/DictationSessionController.swift | 116 +++++- .../Overlay/FloatingOverlayController.swift | 30 +- Tests/ClipboardRestoringTextPasterTests.swift | 361 ++++++++++++++++++ docs/privacy-first-observability.md | 3 + scripts/entrypoints/run-tests.sh | 1 + 8 files changed, 697 insertions(+), 46 deletions(-) create mode 100644 Sources/Observability/DictationPasteRetryTelemetry.swift diff --git a/Resources/analytics-events.psv b/Resources/analytics-events.psv index 625e009d..eeb47332 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 00000000..74f916cb --- /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 2f7f5710..c3c3dc42 100644 --- a/Sources/Support/ClipboardRestoringTextPaster.swift +++ b/Sources/Support/ClipboardRestoringTextPaster.swift @@ -7,6 +7,12 @@ import ApplicationServices import Carbon import Foundation +private enum ClipboardPasteConfirmationWaitResult: Equatable { + case confirmed + case unconfirmed + case focusChanged +} + enum TextPasteCopyReason: Equatable { case accessibilityMissing case pasteEventCreationFailed @@ -541,6 +547,25 @@ private struct FocusedTextPasteConfirmation { } } +@MainActor +protocol ClipboardPasteConfirmationSource { + var canObservePaste: Bool { get } + + func confirmationMode( + _ text: String, + clipboardWasRead: Bool, + clipboardReadAt: CFAbsoluteTime?, + pasteDispatchedAt: CFAbsoluteTime + ) -> String? + + func diagnosticsContext( + clipboardReadAt: CFAbsoluteTime?, + pasteDispatchedAt: CFAbsoluteTime + ) -> [String: String] +} + +extension FocusedTextPasteConfirmation: ClipboardPasteConfirmationSource {} + @MainActor final class ClipboardRestoringTextPaster { struct PasteboardSnapshot { @@ -560,6 +585,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? @@ -571,6 +597,11 @@ final class ClipboardRestoringTextPaster { func cancelPendingClipboardRestore() { restorePendingClipboardNow() + restoreRetainedClipboardNow() + } + + func discardPasteRetry() { + retainedClipboardRestoreForPasteRetry = nil } func restorePendingClipboardNow() { @@ -604,6 +635,46 @@ final class ClipboardRestoringTextPaster { await waitForPendingClipboardRestore() } + /// Retries only the paste gesture. Auto Enter and a second retry are excluded + /// by construction so a user recovery action 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, + confirmationSource: (@MainActor () -> (any ClipboardPasteConfirmationSource)?)? = nil, + 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, + confirmationSource: confirmationSource, + pasteConfirmed: pasteConfirmed, + targetIsFrontmost: targetIsFrontmost, + prepareForAutoSend: false, + retainClipboardForPasteRetry: false, + restoreDelay: restoreDelay, + fallbackRestoreDelay: fallbackRestoreDelay, + pasteConfirmationWait: pasteConfirmationWait + ) + } + func paste( _ text: String, target: DictationPasteTarget? = nil, @@ -615,13 +686,17 @@ final class ClipboardRestoringTextPaster { _ = AXIsProcessTrustedWithOptions(options) }, pasteDispatcher: @MainActor () -> Bool = postClipboardPasteShortcut, + confirmationSource: (@MainActor () -> (any ClipboardPasteConfirmationSource)?)? = nil, 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, @@ -643,7 +718,7 @@ final class ClipboardRestoringTextPaster { ) } - let accessibilityConfirmation = FocusedTextPasteConfirmation.capture() + let accessibilityConfirmation = confirmationSource?() ?? FocusedTextPasteConfirmation.capture() let savedItems = snapshotPasteboardItems(from: pasteboard) guard savedItems.isComplete else { return .failed("Couldn't paste automatically without risking your current clipboard. The dictation was saved, but paste-back did not run.") @@ -686,6 +761,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 +776,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 +792,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 +825,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,13 +905,16 @@ final class ClipboardRestoringTextPaster { ) } - private func leaveTemporaryClipboardAvailable() -> Bool { + private func leaveTemporaryClipboardAvailable( + retainingRestoreForPasteRetry: Bool = false + ) -> Bool { guard let pendingClipboardRestore else { clearPendingClipboardRestore(restore: false) return true } let pasteboard = pendingClipboardRestore.pasteboard + let savedItems = pendingClipboardRestore.savedItems let temporaryString = pendingClipboardRestore.temporaryString let temporaryChangeCount = pendingClipboardRestore.temporaryChangeCount clearPendingClipboardRestore(restore: false) @@ -833,7 +925,55 @@ 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: 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 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) { @@ -943,25 +1083,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 6678f15b..b09e88ad 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,40 +1780,101 @@ 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 @@ -2264,10 +2341,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 61cc610b..a233ac91 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 @@ -266,9 +267,6 @@ class FloatingOverlayController { loadingTimerTask = nil successDismissTask?.cancel() successDismissTask = nil - errorMessage = "" - messageTone = .error - successTitle = "Pasted" let shouldOpenAtCursor = isCursorMiniPanelMode let rawTargetRect = shouldOpenAtCursor @@ -391,8 +389,7 @@ class FloatingOverlayController { cancelMiniLoadingReveal() errorMessage = "" messageTone = .error - errorActionTitle = nil - errorActionHandler = nil + discardActionableMessageIfNeeded() listeningNotice = "" state = .starting resizePanelToCompact() @@ -522,8 +519,7 @@ class FloatingOverlayController { errorDismissTask?.cancel() errorMessage = "" messageTone = .error - errorActionTitle = nil - errorActionHandler = nil + discardActionableMessageIfNeeded() if let presentation { loadingPresentation = presentation } @@ -618,6 +614,7 @@ class FloatingOverlayController { errorDismissTask?.cancel() loadingTimerTask?.cancel() loadingTimerTask = nil + discardActionableMessageIfNeeded() errorMessage = message messageTone = tone errorActionTitle = actionTitle @@ -646,9 +643,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() { @@ -669,8 +674,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 { @@ -693,8 +697,7 @@ class FloatingOverlayController { successDismissTask?.cancel() errorMessage = "" messageTone = .error - errorActionTitle = nil - errorActionHandler = nil + discardActionableMessageIfNeeded() successTitle = title state = .success resizePanelToCompact() @@ -735,8 +738,7 @@ class FloatingOverlayController { state = .idle errorMessage = "" messageTone = .error - errorActionTitle = nil - errorActionHandler = nil + discardActionableMessageIfNeeded() listeningNotice = "" loadingPresentation = .initial } diff --git a/Tests/ClipboardRestoringTextPasterTests.swift b/Tests/ClipboardRestoringTextPasterTests.swift index b49a78de..afc66fc5 100644 --- a/Tests/ClipboardRestoringTextPasterTests.swift +++ b/Tests/ClipboardRestoringTextPasterTests.swift @@ -83,6 +83,155 @@ func testClipboardRestoringTextPaster() async { ) } + runSuite("DictationSessionController — Paste Again is paste-only and single-owner") { + let source = try! String( + contentsOfFile: "Sources/UI/Overlay/DictationSessionController.swift", + encoding: .utf8 + ) + let pasterSource = try! String( + contentsOfFile: "Sources/Support/ClipboardRestoringTextPaster.swift", + encoding: .utf8 + ) + assertTrue( + source.contains("case .copied(let message, reason: .pasteNotConfirmed):") + && source.contains("actionTitle: \"Paste Again\""), + "observable confirmation failures should offer Paste Again" + ) + assertTrue( + pasterSource.contains("prepareForAutoSend: false") + && pasterSource.contains("retainClipboardForPasteRetry: false"), + "Paste Again must not submit Auto Enter or arm another retry" + ) + let retryStart = source.range(of: "private func retryPasteWithoutAutoEnter(") + let retryEnd = retryStart.flatMap { start in + source.range(of: "private func recordPasteAttemptOutcome(", range: start.upperBound.. String? in + guard let retryEnd else { return nil } + return String(source[start.lowerBound.. String? in + guard let retryEnd else { return nil } + return String(source[start.lowerBound.. String? { + guard isFocused else { return nil } + switch kind { + case .codex: + return FocusedTextPasteConfirmationPolicy.didObservePaste( + initialValue: initialText, + currentValue: currentText, + pastedText: text + ) ? "text_value" : nil + case .notes: + return FocusedTextPasteConfirmationPolicy.didObserveSelectionPaste( + initialRange: initialSelection, + currentRange: currentSelection, + pastedText: text, + clipboardWasRead: self.clipboardWasRead || clipboardWasRead + ) ? "selection_range" : nil + case .browser: + return FocusedTextPasteConfirmationPolicy.didObserveTargetChange( + pasteDispatchedAt: pasteDispatchedAt, + clipboardReadAt: clipboardReadAt, + targetChangedAt: targetChangedAt + ) ? "target_change_notification" : nil + } + } + + func diagnosticsContext( + clipboardReadAt: CFAbsoluteTime?, + pasteDispatchedAt: CFAbsoluteTime + ) -> [String: String] { + [ + "clipboard_read_after_dispatch": "\((clipboardReadAt ?? 0) >= pasteDispatchedAt)", + "target_change_after_dispatch": "\((targetChangedAt ?? 0) >= pasteDispatchedAt)", + "target_change_observer_available": kind == .browser ? "true" : "false", + "target_selection_observable": kind == .notes ? "true" : "false", + "target_text_observable": kind == .codex ? "true" : "false", + ] + } +} + @MainActor private final class FakeClipboardPasteboard: ClipboardPasteboard { var changeCount = 0 diff --git a/docs/privacy-first-observability.md b/docs/privacy-first-observability.md index e1ddaae8..2a430971 100644 --- a/docs/privacy-first-observability.md +++ b/docs/privacy-first-observability.md @@ -131,6 +131,7 @@ allowlist. - `dictation_started` - `dictation_start_failed` - `dictation_completed` +- `dictation_paste_retry_completed` - `dictation_artifact_saved` - `dictation_stop_latency_measured` - `dictation_cancelled` @@ -196,6 +197,8 @@ allowlist. shape for the saved-meeting -> summary -> return loop - live transcript drawer analytics limited to `action_kind`, `surface`, `trigger`, and `result`; never live transcript text or meeting context +- paste retry analytics limited to `result` and a coarse `reason`; never text, + capture identifiers, or target-app identifiers - local meeting summary analytics limited to `provider`, `summary_action`, `setup_ready`, `runtime`, `queue_depth_bucket`, `chunk_count_bucket`, `duration_bucket`, `failure_kind`, `result`, and `stage` diff --git a/scripts/entrypoints/run-tests.sh b/scripts/entrypoints/run-tests.sh index 0f823cde..1b1c7003 100755 --- a/scripts/entrypoints/run-tests.sh +++ b/scripts/entrypoints/run-tests.sh @@ -338,6 +338,7 @@ APP_SOURCES=( "Sources/UI/MenuBar/MenuBarHeaderStatusPresentation.swift" "Sources/UI/MenuBar/PasteLastDictationFeedback.swift" "Sources/Observability/AnalyticsReporter.swift" + "Sources/Observability/DictationPasteRetryTelemetry.swift" "Sources/Observability/SpeakerRecognitionTelemetry.swift" "Sources/Observability/ActivationTelemetry.swift" "Sources/Observability/FeatureDiscoveryTelemetry.swift" From 7c675a620db4418fa306391292d16f5618209b85 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 17 Jul 2026 17:54:36 -0500 Subject: [PATCH 2/3] Fix paste attempt helper return --- Sources/UI/Overlay/DictationSessionController.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/UI/Overlay/DictationSessionController.swift b/Sources/UI/Overlay/DictationSessionController.swift index b09e88ad..0bbe5381 100644 --- a/Sources/UI/Overlay/DictationSessionController.swift +++ b/Sources/UI/Overlay/DictationSessionController.swift @@ -1879,8 +1879,6 @@ class DictationSessionController: ObservableObject { case nil: break } - - return outcome } private func retargetPasteToCurrentFocus() { From 49d0dfca1057e73df19caa2f5c671edd87b79183 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 17 Jul 2026 18:05:22 -0500 Subject: [PATCH 3/3] Restore clipboard when Paste Again is superseded --- .../ClipboardRestoringTextPaster.swift | 2 +- Tests/ClipboardRestoringTextPasterTests.swift | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/Sources/Support/ClipboardRestoringTextPaster.swift b/Sources/Support/ClipboardRestoringTextPaster.swift index c3c3dc42..77f10d64 100644 --- a/Sources/Support/ClipboardRestoringTextPaster.swift +++ b/Sources/Support/ClipboardRestoringTextPaster.swift @@ -601,7 +601,7 @@ final class ClipboardRestoringTextPaster { } func discardPasteRetry() { - retainedClipboardRestoreForPasteRetry = nil + restoreRetainedClipboardNow() } func restorePendingClipboardNow() { diff --git a/Tests/ClipboardRestoringTextPasterTests.swift b/Tests/ClipboardRestoringTextPasterTests.swift index afc66fc5..acd46730 100644 --- a/Tests/ClipboardRestoringTextPasterTests.swift +++ b/Tests/ClipboardRestoringTextPasterTests.swift @@ -885,6 +885,44 @@ func testClipboardRestoringTextPaster() async { assertEqual(restoredClipboard, originalClipboard, "cancellation should restore the owned clipboard snapshot") } + await runSuite("ClipboardRestoringTextPaster.discardPasteRetry — restores superseded retry snapshot") { + let originalClipboard = "synthetic superseded clipboard" + let dictationText = "synthetic superseded retry" + let pasteboard = await MainActor.run { + FakeClipboardPasteboard(initialString: originalClipboard) + } + let paster = await MainActor.run { ClipboardRestoringTextPaster() } + let adapter = await MainActor.run { + SyntheticPasteTargetAdapter(kind: .browser, appliesPaste: false) + } + + await MainActor.run { + _ = paster.paste( + dictationText, + pasteboard: pasteboard, + accessibilityTrusted: { true }, + requestAccessibilityTrust: {}, + pasteDispatcher: { + adapter.receivePaste(dictationText, clipboardRead: false) + return true + }, + confirmationSource: { adapter }, + targetIsFrontmost: { adapter.isFocused }, + pasteConfirmationWait: 0 + ) + paster.discardPasteRetry() + } + + let restoredClipboard = await MainActor.run { + pasteboard.string(forType: .string) + } + assertEqual( + restoredClipboard, + originalClipboard, + "superseding Paste Again must restore the owned clipboard snapshot" + ) + } + await runSuite("ClipboardRestoringTextPaster.paste — confirmed target read restores clipboard") { if ProcessInfo.processInfo.environment["TRANSCRIPTED_SKIP_TIMING_SENSITIVE_TESTS"] == "1" { print(" SKIPPED: wall-clock timing proof — scheduler jitter on shared CI runners makes the 30/80ms windows unprovable there; covered by local runs")