diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/AssistantTextParser.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/AssistantTextParser.swift index 2ec4332cd24..afcacf3bdc7 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/AssistantTextParser.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/AssistantTextParser.swift @@ -1,12 +1,19 @@ import Foundation -struct AssistantTextSegment: Identifiable { - enum Kind { +struct AssistantTextSegment: Identifiable, Equatable { + enum Kind: Equatable { case thinking case response + + var stableIDPrefix: String { + switch self { + case .thinking: "t" + case .response: "r" + } + } } - let id = UUID() + let id: String let kind: Kind let text: String } @@ -16,7 +23,7 @@ enum AssistantTextParser { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return [] } guard raw.contains("<") else { - return [AssistantTextSegment(kind: .response, text: trimmed)] + return [AssistantTextSegment(id: "r0", kind: .response, text: trimmed)] } var segments: [AssistantTextSegment] = [] @@ -51,7 +58,7 @@ enum AssistantTextParser { } guard matchedTag else { - return [AssistantTextSegment(kind: .response, text: trimmed)] + return [AssistantTextSegment(id: "r0", kind: .response, text: trimmed)] } if includeThinking { @@ -146,6 +153,11 @@ enum AssistantTextParser { { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } - segments.append(AssistantTextSegment(kind: kind, text: trimmed)) + let nextIndex = segments.count + segments.append( + AssistantTextSegment( + id: "\(kind.stableIDPrefix)\(nextIndex)", + kind: kind, + text: trimmed)) } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift index c3ca0a344be..729455cac3e 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift @@ -17,6 +17,7 @@ public struct OpenClawChatView: View { @State private var hasPerformedInitialScroll = false @State private var isPinnedToBottom = true @State private var lastUserMessageID: UUID? + @State private var pendingStreamingScrollTask: Task? private let showsSessionSwitcher: Bool private let style: Style private let markdownVariant: ChatMarkdownVariant @@ -83,6 +84,10 @@ public struct OpenClawChatView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .onAppear { self.viewModel.load() } + .onDisappear { + self.pendingStreamingScrollTask?.cancel() + self.pendingStreamingScrollTask = nil + } .sheet(isPresented: self.$showSessions) { if self.showsSessionSwitcher { ChatSessionsSheet(viewModel: self.viewModel) @@ -180,6 +185,16 @@ public struct OpenClawChatView: View { } .onChange(of: self.viewModel.streamingAssistantText) { _, _ in guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return } + self.scheduleStreamingScrollToBottom() + } + } + + private func scheduleStreamingScrollToBottom() { + guard self.pendingStreamingScrollTask == nil else { return } + self.pendingStreamingScrollTask = Task { @MainActor in + defer { self.pendingStreamingScrollTask = nil } + try? await Task.sleep(nanoseconds: 100_000_000) + guard !Task.isCancelled, self.hasPerformedInitialScroll, self.isPinnedToBottom else { return } withAnimation(.snappy(duration: 0.22)) { self.scrollPosition = self.scrollerBottomID } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AssistantTextParserTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AssistantTextParserTests.swift index a531bbebb49..22598d98584 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AssistantTextParserTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AssistantTextParserTests.swift @@ -48,4 +48,13 @@ import Testing #expect(AssistantTextParser.hasVisibleContent(in: "internal") == false) #expect(AssistantTextParser.hasVisibleContent(in: "internal", includeThinking: true)) } + + @Test func usesStableSegmentIDsAcrossRepeatedStreamingParses() { + let raw = "internal\n\nHello there" + let first = AssistantTextParser.segments(from: raw, includeThinking: true) + let second = AssistantTextParser.segments(from: raw, includeThinking: true) + + #expect(first.map(\.id) == ["t0", "r1"]) + #expect(first == second) + } }