From 7612a83fa2e96688b09e0613863ab06bc2d4c193 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 30 Dec 2025 06:47:19 +0100 Subject: [PATCH] fix(talk): align sessions and chat UI --- .../com/steipete/clawdis/node/NodeRuntime.kt | 14 ++++- .../clawdis/node/voice/TalkModeManager.kt | 59 ++++++++++++------- apps/ios/Sources/Chat/ChatSheet.swift | 6 +- apps/ios/Sources/Model/NodeAppModel.swift | 9 ++- apps/ios/Sources/RootCanvas.swift | 5 +- apps/ios/Sources/Voice/TalkModeManager.swift | 53 ++++++++++------- .../Sources/Clawdis/TalkModeRuntime.swift | 10 +++- .../Sources/Clawdis/WebChatSwiftUI.swift | 14 ++++- .../ClawdisChatUI/ChatMessageViews.swift | 6 +- .../Sources/ClawdisChatUI/ChatTheme.swift | 6 +- .../Sources/ClawdisChatUI/ChatView.swift | 7 ++- .../Sources/ClawdisChatUI/ChatViewModel.swift | 29 ++++++++- .../ClawdisKitTests/ChatViewModelTests.swift | 23 ++++++++ 13 files changed, 181 insertions(+), 60 deletions(-) diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt index 396eb65e9..7a68abbd3 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt @@ -70,7 +70,7 @@ class NodeRuntime(context: Context) { payloadJson = buildJsonObject { put("message", JsonPrimitive(command)) - put("sessionKey", JsonPrimitive("main")) + put("sessionKey", JsonPrimitive(mainSessionKey.value)) put("thinking", JsonPrimitive(chatThinkingLevel.value)) put("deliver", JsonPrimitive(false)) }.toString(), @@ -104,6 +104,9 @@ class NodeRuntime(context: Context) { private val _statusText = MutableStateFlow("Offline") val statusText: StateFlow = _statusText.asStateFlow() + private val _mainSessionKey = MutableStateFlow("main") + val mainSessionKey: StateFlow = _mainSessionKey.asStateFlow() + private val cameraHudSeq = AtomicLong(0) private val _cameraHud = MutableStateFlow(null) val cameraHud: StateFlow = _cameraHud.asStateFlow() @@ -161,6 +164,7 @@ class NodeRuntime(context: Context) { _remoteAddress.value = null _isConnected.value = false _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB + _mainSessionKey.value = "main" chat.onDisconnected(message) showLocalCanvasOnDisconnect() } @@ -632,8 +636,12 @@ class NodeRuntime(context: Context) { val config = root?.get("config").asObjectOrNull() val ui = config?.get("ui").asObjectOrNull() val raw = ui?.get("seamColor").asStringOrNull()?.trim() - val parsed = parseHexColorArgb(raw) ?: return - _seamColorArgb.value = parsed + val sessionCfg = config?.get("session").asObjectOrNull() + val rawMainKey = sessionCfg?.get("mainKey").asStringOrNull()?.trim() + _mainSessionKey.value = rawMainKey?.takeIf { it.isNotEmpty() } ?: "main" + + val parsed = parseHexColorArgb(raw) + _seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB } catch (_: Throwable) { // ignore } diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/voice/TalkModeManager.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/voice/TalkModeManager.kt index de32c95c3..e015aafcf 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/voice/TalkModeManager.kt @@ -80,18 +80,19 @@ class TalkModeManager( private var interruptOnSpeech: Boolean = true private var voiceOverrideActive = false private var modelOverrideActive = false + private var mainSessionKey: String = "main" private var session: BridgeSession? = null private var pendingRunId: String? = null private var pendingFinal: CompletableDeferred? = null - private var chatSubscribed = false + private var chatSubscribedSessionKey: String? = null private var player: MediaPlayer? = null private var currentAudioFile: File? = null fun attachSession(session: BridgeSession) { this.session = session - chatSubscribed = false + chatSubscribedSessionKey = null } fun setEnabled(enabled: Boolean) { @@ -173,7 +174,7 @@ class TalkModeManager( _isListening.value = false _statusText.value = "Off" stopSpeaking() - chatSubscribed = false + chatSubscribedSessionKey = null mainHandler.post { recognizer?.cancel() @@ -281,18 +282,15 @@ class TalkModeManager( try { val startedAt = System.currentTimeMillis().toDouble() / 1000.0 - subscribeChatIfNeeded(bridge = bridge, sessionKey = "main") - Log.d(tag, "chat.send start chars=${prompt.length}") + subscribeChatIfNeeded(bridge = bridge, sessionKey = mainSessionKey) + Log.d(tag, "chat.send start sessionKey=${mainSessionKey.ifBlank { "main" }} chars=${prompt.length}") val runId = sendChat(prompt, bridge) Log.d(tag, "chat.send ok runId=$runId") val ok = waitForChatFinal(runId) if (!ok) { - _statusText.value = "No reply" - Log.w(tag, "chat final timeout runId=$runId") - start() - return + Log.w(tag, "chat final timeout runId=$runId; attempting history fallback") } - val assistant = waitForAssistantText(bridge, startedAt, 12_000) + val assistant = waitForAssistantText(bridge, startedAt, if (ok) 12_000 else 25_000) if (assistant.isNullOrBlank()) { _statusText.value = "No reply" Log.w(tag, "assistant text timeout runId=$runId") @@ -312,12 +310,12 @@ class TalkModeManager( } private suspend fun subscribeChatIfNeeded(bridge: BridgeSession, sessionKey: String) { - if (chatSubscribed) return val key = sessionKey.trim() if (key.isEmpty()) return + if (chatSubscribedSessionKey == key) return try { bridge.sendEvent("chat.subscribe", """{"sessionKey":"$key"}""") - chatSubscribed = true + chatSubscribedSessionKey = key Log.d(tag, "chat.subscribe ok sessionKey=$key") } catch (err: Throwable) { Log.w(tag, "chat.subscribe failed sessionKey=$key err=${err.message ?: err::class.java.simpleName}") @@ -342,7 +340,7 @@ class TalkModeManager( val runId = UUID.randomUUID().toString() val params = buildJsonObject { - put("sessionKey", JsonPrimitive("main")) + put("sessionKey", JsonPrimitive(mainSessionKey.ifBlank { "main" })) put("message", JsonPrimitive(message)) put("thinking", JsonPrimitive("low")) put("timeoutMs", JsonPrimitive(30_000)) @@ -396,7 +394,8 @@ class TalkModeManager( bridge: BridgeSession, sinceSeconds: Double? = null, ): String? { - val res = bridge.request("chat.history", "{\"sessionKey\":\"main\"}") + val key = mainSessionKey.ifBlank { "main" } + val res = bridge.request("chat.history", "{\"sessionKey\":\"$key\"}") val root = json.parseToJsonElement(res).asObjectOrNull() ?: return null val messages = root["messages"] as? JsonArray ?: return null for (item in messages.reversed()) { @@ -404,7 +403,7 @@ class TalkModeManager( if (obj["role"].asStringOrNull() != "assistant") continue if (sinceSeconds != null) { val timestamp = obj["timestamp"].asDoubleOrNull() - if (timestamp != null && timestamp < sinceSeconds - 0.5) continue + if (timestamp != null && !TalkModeRuntime.isMessageTimestampAfter(timestamp, sinceSeconds)) continue } val content = obj["content"] as? JsonArray ?: continue val text = @@ -438,16 +437,15 @@ class TalkModeManager( } } + val apiKey = + apiKey?.trim()?.takeIf { it.isNotEmpty() } + ?: System.getenv("ELEVENLABS_API_KEY")?.trim() val voiceId = directive?.voiceId ?: currentVoiceId ?: defaultVoiceId if (voiceId.isNullOrBlank()) { _statusText.value = "Missing voice ID" Log.w(tag, "missing voiceId") return } - - val apiKey = - apiKey?.trim()?.takeIf { it.isNotEmpty() } - ?: System.getenv("ELEVENLABS_API_KEY")?.trim() if (apiKey.isNullOrEmpty()) { _statusText.value = "Missing ELEVENLABS_API_KEY" Log.w(tag, "missing ELEVENLABS_API_KEY") @@ -465,7 +463,8 @@ class TalkModeManager( ElevenLabsRequest( text = cleaned, modelId = directive?.modelId ?: currentModelId ?: defaultModelId, - outputFormat = directive?.outputFormat ?: defaultOutputFormat, + outputFormat = + TalkModeRuntime.validatedOutputFormat(directive?.outputFormat ?: defaultOutputFormat), speed = TalkModeRuntime.resolveSpeed(directive?.speed, directive?.rateWpm), stability = TalkModeRuntime.validatedUnit(directive?.stability), similarity = TalkModeRuntime.validatedUnit(directive?.similarity), @@ -564,12 +563,15 @@ class TalkModeManager( val root = json.parseToJsonElement(res).asObjectOrNull() val config = root?.get("config").asObjectOrNull() val talk = config?.get("talk").asObjectOrNull() + val sessionCfg = config?.get("session").asObjectOrNull() + val mainKey = sessionCfg?.get("mainKey").asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: "main" val voice = talk?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } val model = talk?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } val outputFormat = talk?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } val key = talk?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull() + mainSessionKey = mainKey defaultVoiceId = voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } if (!voiceOverrideActive) currentVoiceId = defaultVoiceId defaultModelId = model @@ -593,6 +595,8 @@ class TalkModeManager( val url = URL("https://api.elevenlabs.io/v1/text-to-speech/$voiceId") val conn = url.openConnection() as HttpURLConnection conn.requestMethod = "POST" + conn.connectTimeout = 30_000 + conn.readTimeout = 30_000 conn.setRequestProperty("Content-Type", "application/json") conn.setRequestProperty("Accept", "audio/mpeg") conn.setRequestProperty("xi-api-key", apiKey) @@ -689,6 +693,21 @@ class TalkModeManager( if (!normalized.all { it in 'a'..'z' }) return null return normalized } + + fun validatedOutputFormat(value: String?): String? { + val trimmed = value?.trim()?.lowercase() ?: return null + if (trimmed.isEmpty()) return null + return if (trimmed.startsWith("mp3_")) trimmed else null + } + + fun isMessageTimestampAfter(timestamp: Double, sinceSeconds: Double): Boolean { + val sinceMs = sinceSeconds * 1000 + return if (timestamp > 10_000_000_000) { + timestamp >= sinceMs - 500 + } else { + timestamp >= sinceSeconds - 0.5 + } + } } private fun ensureInterruptListener() { diff --git a/apps/ios/Sources/Chat/ChatSheet.swift b/apps/ios/Sources/Chat/ChatSheet.swift index 706a6b789..1d2d059bb 100644 --- a/apps/ios/Sources/Chat/ChatSheet.swift +++ b/apps/ios/Sources/Chat/ChatSheet.swift @@ -4,18 +4,20 @@ import SwiftUI struct ChatSheet: View { @Environment(\.dismiss) private var dismiss @State private var viewModel: ClawdisChatViewModel + private let userAccent: Color? - init(bridge: BridgeSession, sessionKey: String = "main") { + init(bridge: BridgeSession, sessionKey: String = "main", userAccent: Color? = nil) { let transport = IOSBridgeChatTransport(bridge: bridge) self._viewModel = State( initialValue: ClawdisChatViewModel( sessionKey: sessionKey, transport: transport)) + self.userAccent = userAccent } var body: some View { NavigationStack { - ClawdisChatView(viewModel: self.viewModel) + ClawdisChatView(viewModel: self.viewModel, userAccent: self.userAccent) .navigationTitle("Chat") .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 26b5e4f1a..805cd7638 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -23,6 +23,7 @@ final class NodeAppModel { var bridgeRemoteAddress: String? var connectedBridgeID: String? var seamColorHex: String? + var mainSessionKey: String = "main" private let bridge = BridgeSession() private var bridgeTask: Task? @@ -42,7 +43,7 @@ final class NodeAppModel { init() { self.voiceWake.configure { [weak self] cmd in guard let self else { return } - let sessionKey = "main" + let sessionKey = self.mainSessionKey do { try await self.sendVoiceTranscript(text: cmd, sessionKey: sessionKey) } catch { @@ -267,6 +268,7 @@ final class NodeAppModel { self.bridgeRemoteAddress = nil self.connectedBridgeID = nil self.seamColorHex = nil + self.mainSessionKey = "main" self.showLocalCanvasOnDisconnect() } } @@ -283,6 +285,7 @@ final class NodeAppModel { self.bridgeRemoteAddress = nil self.connectedBridgeID = nil self.seamColorHex = nil + self.mainSessionKey = "main" self.showLocalCanvasOnDisconnect() } @@ -310,8 +313,12 @@ final class NodeAppModel { guard let config = json["config"] as? [String: Any] else { return } let ui = config["ui"] as? [String: Any] let raw = (ui?["seamColor"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let session = config["session"] as? [String: Any] + let rawMainKey = (session?["mainKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let mainKey = rawMainKey.isEmpty ? "main" : rawMainKey await MainActor.run { self.seamColorHex = raw.isEmpty ? nil : raw + self.mainSessionKey = mainKey } } catch { // ignore diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index 2ca28a15b..9a7360c33 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -51,7 +51,10 @@ struct RootCanvas: View { case .settings: SettingsTab() case .chat: - ChatSheet(bridge: self.appModel.bridgeSession) + ChatSheet( + bridge: self.appModel.bridgeSession, + sessionKey: self.appModel.mainSessionKey, + userAccent: self.appModel.seamColor) } } .onAppear { self.updateIdleTimer() } diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index 9eb441ce6..a52774b27 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -34,6 +34,7 @@ final class TalkModeManager: NSObject { private var defaultOutputFormat: String? private var apiKey: String? private var interruptOnSpeech: Bool = true + private var mainSessionKey: String = "main" private var bridge: BridgeSession? private let silenceWindow: TimeInterval = 0.7 @@ -84,7 +85,7 @@ final class TalkModeManager: NSObject { self.isListening = true self.statusText = "Listening" self.startSilenceMonitor() - await self.subscribeChatIfNeeded(sessionKey: "main") + await self.subscribeChatIfNeeded(sessionKey: self.mainSessionKey) self.logger.info("listening") } catch { self.isListening = false @@ -227,25 +228,22 @@ final class TalkModeManager: NSObject { do { let startedAt = Date().timeIntervalSince1970 - await self.subscribeChatIfNeeded(sessionKey: "main") - self.logger.info("chat.send start chars=\(prompt.count, privacy: .public)") + let sessionKey = self.mainSessionKey + await self.subscribeChatIfNeeded(sessionKey: sessionKey) + self.logger.info("chat.send start sessionKey=\(sessionKey, privacy: .public) chars=\(prompt.count, privacy: .public)") let runId = try await self.sendChat(prompt, bridge: bridge) self.logger.info("chat.send ok runId=\(runId, privacy: .public)") let completion = await self.waitForChatCompletion(runId: runId, bridge: bridge, timeoutSeconds: 120) - guard completion == .final else { - switch completion { - case .timeout: - self.statusText = "No reply" - self.logger.warning("chat completion timeout runId=\(runId, privacy: .public)") - case .aborted: - self.statusText = "Aborted" - self.logger.warning("chat completion aborted runId=\(runId, privacy: .public)") - case .error: - self.statusText = "Chat error" - self.logger.warning("chat completion error runId=\(runId, privacy: .public)") - case .final: - break - } + if completion == .timeout { + self.logger.warning("chat completion timeout runId=\(runId, privacy: .public); attempting history fallback") + } else if completion == .aborted { + self.statusText = "Aborted" + self.logger.warning("chat completion aborted runId=\(runId, privacy: .public)") + await self.start() + return + } else if completion == .error { + self.statusText = "Chat error" + self.logger.warning("chat completion error runId=\(runId, privacy: .public)") await self.start() return } @@ -253,7 +251,7 @@ final class TalkModeManager: NSObject { guard let assistantText = try await self.waitForAssistantText( bridge: bridge, since: startedAt, - timeoutSeconds: 12) + timeoutSeconds: completion == .final ? 12 : 25) else { self.statusText = "No reply" self.logger.warning("assistant text timeout runId=\(runId, privacy: .public)") @@ -338,7 +336,7 @@ final class TalkModeManager: NSObject { private func sendChat(_ message: String, bridge: BridgeSession) async throws -> String { struct SendResponse: Decodable { let runId: String } let payload: [String: Any] = [ - "sessionKey": "main", + "sessionKey": self.mainSessionKey, "message": message, "thinking": "low", "timeoutMs": 30000, @@ -404,13 +402,15 @@ final class TalkModeManager: NSObject { private func fetchLatestAssistantText(bridge: BridgeSession, since: Double? = nil) async throws -> String? { let res = try await bridge.request( method: "chat.history", - paramsJSON: "{\"sessionKey\":\"main\"}", + paramsJSON: "{\"sessionKey\":\"\(self.mainSessionKey)\"}", timeoutSeconds: 15) guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return nil } guard let messages = json["messages"] as? [[String: Any]] else { return nil } for msg in messages.reversed() { guard (msg["role"] as? String) == "assistant" else { continue } - if let since, let timestamp = msg["timestamp"] as? Double, timestamp < since - 0.5 { + if let since, let timestamp = msg["timestamp"] as? Double, + TalkModeRuntime.isMessageTimestampAfter(timestamp, sinceSeconds: since) == false + { continue } guard let content = msg["content"] as? [[String: Any]] else { continue } @@ -560,6 +560,9 @@ final class TalkModeManager: NSObject { guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return } guard let config = json["config"] as? [String: Any] else { return } let talk = config["talk"] as? [String: Any] + let session = config["session"] as? [String: Any] + let rawMainKey = (session?["mainKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + self.mainSessionKey = rawMainKey.isEmpty ? "main" : rawMainKey self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) if !self.voiceOverrideActive { self.currentVoiceId = self.defaultVoiceId @@ -721,4 +724,12 @@ private enum TalkModeRuntime { guard !trimmed.isEmpty else { return nil } return trimmed.hasPrefix("mp3_") ? trimmed : nil } + + static func isMessageTimestampAfter(_ timestamp: Double, sinceSeconds: Double) -> Bool { + let sinceMs = sinceSeconds * 1000 + if timestamp > 10_000_000_000 { + return timestamp >= sinceMs - 500 + } + return timestamp >= sinceSeconds - 0.5 + } } diff --git a/apps/macos/Sources/Clawdis/TalkModeRuntime.swift b/apps/macos/Sources/Clawdis/TalkModeRuntime.swift index e090cfebe..8b2dd7061 100644 --- a/apps/macos/Sources/Clawdis/TalkModeRuntime.swift +++ b/apps/macos/Sources/Clawdis/TalkModeRuntime.swift @@ -378,7 +378,7 @@ actor TalkModeRuntime { guard message.role == "assistant" else { return false } guard let since else { return true } guard let timestamp = message.timestamp else { return false } - return timestamp >= since - 0.5 + return Self.isMessageTimestampAfter(timestamp, sinceSeconds: since) } guard let assistant else { return nil } let text = assistant.content.compactMap { $0.text }.joined(separator: "\n") @@ -739,6 +739,14 @@ actor TalkModeRuntime { } return trimmed } + + private static func isMessageTimestampAfter(_ timestamp: Double, sinceSeconds: Double) -> Bool { + let sinceMs = sinceSeconds * 1000 + if timestamp > 10_000_000_000 { + return timestamp >= sinceMs - 500 + } + return timestamp >= sinceSeconds - 0.5 + } } private struct ElevenLabsRequest { diff --git a/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift b/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift index b47d140ee..d396bb286 100644 --- a/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift +++ b/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift @@ -155,7 +155,8 @@ final class WebChatSwiftUIWindowController { self.sessionKey = sessionKey self.presentation = presentation let vm = ClawdisChatViewModel(sessionKey: sessionKey, transport: transport) - self.hosting = NSHostingController(rootView: ClawdisChatView(viewModel: vm)) + let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex) + self.hosting = NSHostingController(rootView: ClawdisChatView(viewModel: vm, userAccent: accent)) self.contentController = Self.makeContentController(for: presentation, hosting: self.hosting) self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController) } @@ -355,4 +356,15 @@ final class WebChatSwiftUIWindowController { window.setFrame(frame, display: false) } } + + private static func color(fromHex raw: String?) -> Color? { + let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed + guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil } + let r = Double((value >> 16) & 0xFF) / 255.0 + let g = Double((value >> 8) & 0xFF) / 255.0 + let b = Double(value & 0xFF) / 255.0 + return Color(red: r, green: g, blue: b) + } } diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift index 0b14e852a..bd8e97c52 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift @@ -137,9 +137,10 @@ private struct ChatBubbleShape: InsettableShape { struct ChatMessageBubble: View { let message: ClawdisChatMessage let style: ClawdisChatView.Style + let userAccent: Color? var body: some View { - ChatMessageBody(message: self.message, isUser: self.isUser, style: self.style) + ChatMessageBody(message: self.message, isUser: self.isUser, style: self.style, userAccent: self.userAccent) .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading) .frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading) .padding(.horizontal, 2) @@ -153,6 +154,7 @@ private struct ChatMessageBody: View { let message: ClawdisChatMessage let isUser: Bool let style: ClawdisChatView.Style + let userAccent: Color? var body: some View { let text = self.primaryText @@ -287,7 +289,7 @@ private struct ChatMessageBody: View { private var bubbleFillColor: Color { if self.isUser { - return ClawdisChatTheme.userBubble + return self.userAccent ?? ClawdisChatTheme.userBubble } if self.style == .onboarding { return ClawdisChatTheme.onboardingAssistantBubble diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatTheme.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatTheme.swift index ac5466c9c..33ed55e94 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatTheme.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatTheme.swift @@ -101,11 +101,7 @@ enum ClawdisChatTheme { } static var userBubble: Color { - #if os(macOS) - Color(nsColor: .systemBlue) - #else - Color(uiColor: .systemBlue) - #endif + Color(red: 127 / 255.0, green: 184 / 255.0, blue: 212 / 255.0) } static var assistantBubble: Color { diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift index acba80385..621b9fbc5 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift @@ -13,6 +13,7 @@ public struct ClawdisChatView: View { @State private var hasPerformedInitialScroll = false private let showsSessionSwitcher: Bool private let style: Style + private let userAccent: Color? private enum Layout { #if os(macOS) @@ -37,11 +38,13 @@ public struct ClawdisChatView: View { public init( viewModel: ClawdisChatViewModel, showsSessionSwitcher: Bool = false, - style: Style = .standard) + style: Style = .standard, + userAccent: Color? = nil) { self._viewModel = State(initialValue: viewModel) self.showsSessionSwitcher = showsSessionSwitcher self.style = style + self.userAccent = userAccent } public var body: some View { @@ -74,7 +77,7 @@ public struct ClawdisChatView: View { ScrollView { LazyVStack(spacing: Layout.messageSpacing) { ForEach(self.visibleMessages) { msg in - ChatMessageBubble(message: msg, style: self.style) + ChatMessageBubble(message: msg, style: self.style, userAccent: self.userAccent) .frame( maxWidth: .infinity, alignment: msg.role.lowercased() == "user" ? .trailing : .leading) diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift index 4936d4438..561736d40 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift @@ -150,9 +150,36 @@ public final class ClawdisChatViewModel { } private static func decodeMessages(_ raw: [AnyCodable]) -> [ClawdisChatMessage] { - raw.compactMap { item in + let decoded = raw.compactMap { item in (try? ChatPayloadDecoding.decode(item, as: ClawdisChatMessage.self)) } + return Self.dedupeMessages(decoded) + } + + private static func dedupeMessages(_ messages: [ClawdisChatMessage]) -> [ClawdisChatMessage] { + var result: [ClawdisChatMessage] = [] + result.reserveCapacity(messages.count) + var seen = Set() + + for message in messages { + guard let key = Self.dedupeKey(for: message) else { + result.append(message) + continue + } + if seen.contains(key) { continue } + seen.insert(key) + result.append(message) + } + + return result + } + + private static func dedupeKey(for message: ClawdisChatMessage) -> String? { + guard let timestamp = message.timestamp else { return nil } + let text = message.content.compactMap { $0.text }.joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + return "\(message.role)|\(timestamp)|\(text)" } private func performSend() async { diff --git a/apps/shared/ClawdisKit/Tests/ClawdisKitTests/ChatViewModelTests.swift b/apps/shared/ClawdisKit/Tests/ClawdisKitTests/ChatViewModelTests.swift index c1522a7fc..b0943fa78 100644 --- a/apps/shared/ClawdisKit/Tests/ClawdisKitTests/ChatViewModelTests.swift +++ b/apps/shared/ClawdisKit/Tests/ClawdisKitTests/ChatViewModelTests.swift @@ -117,6 +117,29 @@ private extension TestChatTransportState { } @Suite struct ChatViewModelTests { + @Test func dedupesDuplicateHistoryMessages() async throws { + let ts = Date().timeIntervalSince1970 * 1000 + let duplicate = AnyCodable([ + "role": "assistant", + "content": [["type": "text", "text": "Same message"]], + "timestamp": ts, + ]) + let history = ClawdisChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [duplicate, duplicate], + thinkingLevel: "off") + + let transport = TestChatTransport(historyResponses: [history]) + let vm = await MainActor.run { ClawdisChatViewModel(sessionKey: "main", transport: transport) } + + await MainActor.run { vm.load() } + try await waitUntil("bootstrap") { await MainActor.run { !vm.messages.isEmpty } } + + #expect(await MainActor.run { vm.messages.count } == 1) + #expect(await MainActor.run { vm.messages.first?.role } == "assistant") + } + @Test func streamsAssistantAndClearsOnFinal() async throws { let sessionId = "sess-main" let history1 = ClawdisChatHistoryPayload(