From 657450c40c90125db7d605796930c98d477edb04 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 10 Dec 2025 02:52:42 +0100 Subject: [PATCH] fix(voice): unify overlay send flow --- .../Sources/Clawdis/VoicePushToTalk.swift | 4 +- .../Clawdis/VoiceSessionCoordinator.swift | 10 ++-- .../Sources/Clawdis/VoiceWakeOverlay.swift | 54 ++++++++----------- .../Sources/Clawdis/VoiceWakeRuntime.swift | 4 +- 4 files changed, 32 insertions(+), 40 deletions(-) diff --git a/apps/macos/Sources/Clawdis/VoicePushToTalk.swift b/apps/macos/Sources/Clawdis/VoicePushToTalk.swift index 7cfd94d00..e33eefe34 100644 --- a/apps/macos/Sources/Clawdis/VoicePushToTalk.swift +++ b/apps/macos/Sources/Clawdis/VoicePushToTalk.swift @@ -288,7 +288,9 @@ actor VoicePushToTalk { VoiceWakeChimePlayer.play(chime, reason: "ptt.fallback_send") } Task.detached { - await VoiceWakeForwarder.forward(transcript: finalText, config: forward) + await VoiceWakeForwarder.forward( + transcript: VoiceWakeForwarder.prefixedTranscript(finalText), + config: forward) } } } diff --git a/apps/macos/Sources/Clawdis/VoiceSessionCoordinator.swift b/apps/macos/Sources/Clawdis/VoiceSessionCoordinator.swift index 91459e088..ff74cf630 100644 --- a/apps/macos/Sources/Clawdis/VoiceSessionCoordinator.swift +++ b/apps/macos/Sources/Clawdis/VoiceSessionCoordinator.swift @@ -21,7 +21,6 @@ final class VoiceSessionCoordinator: ObservableObject { private let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.coordinator") private var session: Session? - private var autoSendTask: Task? // MARK: - API @@ -40,7 +39,7 @@ final class VoiceSessionCoordinator: ObservableObject { let token = UUID() self.logger.info("coordinator start token=\(token.uuidString) source=\(source.rawValue) len=\(text.count)") let attributedText = attributed ?? VoiceWakeOverlayController.shared.makeAttributed(from: text) - self.session = Session( + let session = Session( token: token, source: source, text: text, @@ -49,7 +48,9 @@ final class VoiceSessionCoordinator: ObservableObject { forwardConfig: forwardEnabled ? AppStateStore.shared.voiceWakeForwardConfig : nil, sendChime: .none, autoSendDelay: nil) + self.session = session VoiceWakeOverlayController.shared.startSession( + token: token, source: VoiceWakeOverlayController.Source(rawValue: source.rawValue) ?? .wakeWord, transcript: text, attributed: attributedText, @@ -76,7 +77,6 @@ final class VoiceSessionCoordinator: ObservableObject { self.logger .info( "coordinator finalize token=\(token.uuidString) len=\(text.count) autoSendAfter=\(autoSendAfter ?? -1)") - self.autoSendTask?.cancel(); self.autoSendTask = nil self.session?.text = text self.session?.isFinal = true self.session?.forwardConfig = forwardConfig @@ -108,7 +108,7 @@ final class VoiceSessionCoordinator: ObservableObject { self.clearSession() return } - VoiceWakeOverlayController.shared.sendNow(token: token, sendChime: session.sendChime) + VoiceWakeOverlayController.shared.beginSendUI(token: token, sendChime: session.sendChime) Task.detached { _ = await VoiceWakeForwarder.forward( transcript: VoiceWakeForwarder.prefixedTranscript(text), @@ -139,7 +139,5 @@ final class VoiceSessionCoordinator: ObservableObject { private func clearSession() { self.session = nil - self.autoSendTask?.cancel() - self.autoSendTask = nil } } diff --git a/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift b/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift index 363dded66..2b8abb2e8 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift @@ -46,6 +46,7 @@ final class VoiceWakeOverlayController: ObservableObject { @discardableResult func startSession( + token: UUID = UUID(), source: Source, transcript: String, attributed: NSAttributedString? = nil, @@ -56,7 +57,6 @@ final class VoiceWakeOverlayController: ObservableObject { self.logger.log(level: .info, "overlay drop session_start while sending") return self.activeToken ?? UUID() } - let token = UUID() let message = """ overlay session_start source=\(source.rawValue) \ len=\(transcript.count) @@ -133,9 +133,9 @@ final class VoiceWakeOverlayController: ObservableObject { if let delay { if delay <= 0 { self.logger.log(level: .info, "overlay autoSend immediate token=\(token.uuidString)") - self.sendNow(token: token, sendChime: sendChime) + VoiceSessionCoordinator.shared.sendNow(token: token, reason: "autoSendImmediate") } else { - self.scheduleAutoSend(token: token, after: delay, sendChime: sendChime) + self.scheduleAutoSend(token: token, after: delay) } } } @@ -164,50 +164,41 @@ final class VoiceWakeOverlayController: ObservableObject { self.updateWindowFrame(animate: true) } - func sendNow(token: UUID? = nil, sendChime: VoiceWakeChime = .none) { - guard self.guardToken(token, context: "send") else { return } + /// UI-only path: show sending state and dismiss; actual forwarding is handled by the coordinator. + func beginSendUI(token: UUID, sendChime: VoiceWakeChime = .none) { + guard self.guardToken(token, context: "beginSendUI") else { return } + self.autoSendTask?.cancel(); self.autoSendToken = nil let message = """ - overlay sendNow called token=\(self.activeToken?.uuidString ?? "nil") \ + overlay beginSendUI token=\(token.uuidString) \ isSending=\(self.model.isSending) \ forwardEnabled=\(self.model.forwardEnabled) \ textLen=\(self.model.text.count) """ self.logger.log(level: .info, "\(message)") - self.autoSendTask?.cancel(); self.autoSendToken = nil if self.model.isSending { return } self.model.isEditing = false - guard let forwardConfig, forwardConfig.enabled else { - self.logger.log(level: .info, "overlay sendNow disabled -> dismiss") - self.dismiss(reason: .explicit) - return - } - let text = self.model.text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { - self.logger.log(level: .info, "overlay sendNow empty -> dismiss") - self.dismiss(reason: .empty) - return - } if sendChime != .none { - let message = "overlay sendNow playing sendChime=\(String(describing: sendChime))" + let message = "overlay beginSendUI playing sendChime=\(String(describing: sendChime))" self.logger.log(level: .info, "\(message)") VoiceWakeChimePlayer.play(sendChime, reason: "overlay.send") } self.model.isSending = true - let payload = VoiceWakeForwarder.prefixedTranscript(text) - self.logger.log(level: .info, "overlay sendNow forwarding len=\(payload.count, privacy: .public)") - Task.detached { - await VoiceWakeForwarder.forward(transcript: payload, config: forwardConfig) - } DispatchQueue.main.asyncAfter(deadline: .now() + 0.28) { self.logger.log( level: .info, - "overlay sendNow dismiss ticking token=\(self.activeToken?.uuidString ?? "nil")") + "overlay beginSendUI dismiss ticking token=\(self.activeToken?.uuidString ?? "nil")") self.dismiss(token: token, reason: .explicit, outcome: .sent) } } + func requestSend(token: UUID? = nil, reason: String = "overlay_request") { + guard self.guardToken(token, context: "requestSend") else { return } + guard let active = token ?? self.activeToken else { return } + VoiceSessionCoordinator.shared.sendNow(token: active, reason: reason) + } + func dismiss(token: UUID? = nil, reason: DismissReason = .explicit, outcome: SendOutcome = .empty) { guard self.guardToken(token, context: "dismiss") else { return } let message = """ @@ -408,17 +399,16 @@ final class VoiceWakeOverlayController: ObservableObject { } } - private func scheduleAutoSend(token: UUID, after delay: TimeInterval, sendChime: VoiceWakeChime) { + private func scheduleAutoSend(token: UUID, after delay: TimeInterval) { self.logger.log( level: .info, """ overlay scheduleAutoSend token=\(token.uuidString) \ - after=\(delay) \ - sendChime=\(String(describing: sendChime)) + after=\(delay) """) self.autoSendTask?.cancel() self.autoSendToken = token - self.autoSendTask = Task { [weak self, sendChime, token] in + self.autoSendTask = Task { [weak self, token] in let nanos = UInt64(max(0, delay) * 1_000_000_000) try? await Task.sleep(nanoseconds: nanos) guard !Task.isCancelled else { return } @@ -428,7 +418,7 @@ final class VoiceWakeOverlayController: ObservableObject { self.logger.log( level: .info, "overlay autoSend firing token=\(token.uuidString, privacy: .public)") - self.sendNow(token: token, sendChime: sendChime) + VoiceSessionCoordinator.shared.sendNow(token: token, reason: "autoSendDelay") self.autoSendTask = nil } } @@ -471,7 +461,7 @@ private struct VoiceWakeOverlayView: View { self.controller.endEditing() }, onSend: { - self.controller.sendNow() + self.controller.requestSend() }) .focused(self.$textFocused) .frame(maxWidth: .infinity, minHeight: 32, maxHeight: .infinity, alignment: .topLeading) @@ -488,7 +478,7 @@ private struct VoiceWakeOverlayView: View { } Button { - self.controller.sendNow() + self.controller.requestSend() } label: { let sending = self.controller.model.isSending let level = self.controller.model.level diff --git a/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift b/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift index e11130574..14405458d 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift @@ -351,7 +351,9 @@ actor VoiceWakeRuntime { await MainActor.run { VoiceWakeChimePlayer.play(sendChime, reason: "voicewake.send") } } Task.detached { - await VoiceWakeForwarder.forward(transcript: finalTranscript, config: forwardConfig) + await VoiceWakeForwarder.forward( + transcript: VoiceWakeForwarder.prefixedTranscript(finalTranscript), + config: forwardConfig) } } self.overlayToken = nil