diff --git a/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift b/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift index 78cbbfa80..f4fb8f487 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift @@ -27,6 +27,7 @@ final class VoiceWakeOverlayController: ObservableObject { private var window: NSPanel? private var hostingView: NSHostingView? private var autoSendTask: Task? + private var safetyDismissTask: Task? private var forwardConfig: VoiceWakeForwardConfig? private let width: CGFloat = 360 @@ -41,6 +42,7 @@ final class VoiceWakeOverlayController: ObservableObject { func showPartial(transcript: String, attributed: NSAttributedString? = nil) { self.logger.log(level: .info, "overlay showPartial len=\(transcript.count, privacy: .public) visible=\(self.model.isVisible, privacy: .public) isFinal=false") self.autoSendTask?.cancel() + self.safetyDismissTask?.cancel() self.forwardConfig = nil self.model.text = transcript self.model.isFinal = false @@ -62,6 +64,7 @@ final class VoiceWakeOverlayController: ObservableObject { { self.logger.log(level: .info, "overlay presentFinal len=\(transcript.count, privacy: .public) autoSendAfter=\(delay ?? -1, privacy: .public) forwardEnabled=\(forwardConfig.enabled, privacy: .public)") self.autoSendTask?.cancel() + self.safetyDismissTask?.cancel() self.forwardConfig = forwardConfig self.model.text = transcript self.model.isFinal = true @@ -74,6 +77,8 @@ final class VoiceWakeOverlayController: ObservableObject { if let delay { self.scheduleAutoSend(after: delay, sendChime: sendChime) } + // Safety net: ensure the overlay cannot stick around indefinitely. + self.scheduleSafetyDismiss() } func userBeganEditing() { @@ -104,6 +109,7 @@ final class VoiceWakeOverlayController: ObservableObject { self.logger.log(level: .info, "overlay sendNow called isSending=\(self.model.isSending, privacy: .public) forwardEnabled=\(self.model.forwardEnabled, privacy: .public) textLen=\(self.model.text.count, privacy: .public)") self.autoSendTask?.cancel() self.autoSendTask = nil + self.safetyDismissTask?.cancel() if self.model.isSending { return } self.model.isEditing = false guard let forwardConfig, forwardConfig.enabled else { @@ -137,6 +143,7 @@ final class VoiceWakeOverlayController: ObservableObject { func dismiss(reason: DismissReason = .explicit, outcome: SendOutcome = .empty) { self.logger.log(level: .info, "overlay dismiss reason=\(String(describing: reason), privacy: .public) outcome=\(String(describing: outcome), privacy: .public) visible=\(self.model.isVisible, privacy: .public) sending=\(self.model.isSending, privacy: .public)") self.autoSendTask?.cancel() + self.safetyDismissTask?.cancel() self.model.isSending = false self.model.isEditing = false guard let window else { return } @@ -308,6 +315,20 @@ final class VoiceWakeOverlayController: ObservableObject { } } + private func scheduleSafetyDismiss() { + self.safetyDismissTask?.cancel() + self.safetyDismissTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 6_000_000_000) // 6s + guard !Task.isCancelled else { return } + await MainActor.run { + guard let self, self.model.isVisible else { return } + self.logger.log(level: .info, "overlay safety dismiss firing") + self.dismiss(reason: .explicit) + self.safetyDismissTask = nil + } + } + } + private func makeAttributed(from text: String) -> NSAttributedString { NSAttributedString( string: text,