From 09fd5b03fe7a9ef5d3b266dfeffd6763d125fb59 Mon Sep 17 00:00:00 2001 From: Kenny Lee Date: Mon, 26 Jan 2026 16:29:09 -0800 Subject: [PATCH] fix(macos): use stable message IDs to prevent webchat flicker Generate deterministic UUIDs for chat messages based on role|timestamp|content using SHA256. This prevents SwiftUI from treating refreshed messages as entirely new items, eliminating flicker and preserving scroll position. Fixes #2464 Co-Authored-By: Claude Opus 4.5 --- .../Sources/ClawdbotChatUI/ChatModels.swift | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift index da18963f4..2b60079fb 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift @@ -1,4 +1,5 @@ import ClawdbotKit +import CryptoKit import Foundation // NOTE: keep this file lightweight; decode must be resilient to varying transcript formats. @@ -190,14 +191,12 @@ public struct ClawdbotChatMessage: Codable, Identifiable, Sendable { self.usage = try container.decodeIfPresent(ClawdbotChatUsage.self, forKey: .usage) self.stopReason = try container.decodeIfPresent(String.self, forKey: .stopReason) + // Decode content (supports array or plain string formats). + let decodedContent: [ClawdbotChatMessageContent] if let decoded = try? container.decode([ClawdbotChatMessageContent].self, forKey: .content) { - self.content = decoded - return - } - - // Some session log formats store `content` as a plain string. - if let text = try? container.decode(String.self, forKey: .content) { - self.content = [ + decodedContent = decoded + } else if let text = try? container.decode(String.self, forKey: .content) { + decodedContent = [ ClawdbotChatMessageContent( type: "text", text: text, @@ -210,10 +209,32 @@ public struct ClawdbotChatMessage: Codable, Identifiable, Sendable { name: nil, arguments: nil), ] - return + } else { + decodedContent = [] } + self.content = decodedContent - self.content = [] + // Generate stable ID from content so SwiftUI can track identity across refreshes. + self.id = Self.stableId(role: self.role, timestamp: self.timestamp, content: decodedContent) + } + + /// Generates a deterministic UUID from message content for stable SwiftUI identity. + private static func stableId( + role: String, + timestamp: Double?, + content: [ClawdbotChatMessageContent]) -> UUID + { + let contentText = content.compactMap(\.text).joined(separator: "\n") + let source = "\(role)|\(timestamp ?? 0)|\(contentText)" + let hash = SHA256.hash(data: Data(source.utf8)) + let bytes = Array(hash.prefix(16)) + return UUID( + uuid: ( + bytes[0], bytes[1], bytes[2], bytes[3], + bytes[4], bytes[5], bytes[6], bytes[7], + bytes[8], bytes[9], bytes[10], bytes[11], + bytes[12], bytes[13], bytes[14], bytes[15] + )) } public func encode(to encoder: Encoder) throws {