fix(ios): stabilize streaming layout updates

This commit is contained in:
Eulices Lopez 2026-03-18 13:33:25 -04:00
parent 9c37ce5f9e
commit b76dd70375
3 changed files with 42 additions and 6 deletions

View File

@ -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))
}
}

View File

@ -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<Void, Never>?
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
}

View File

@ -48,4 +48,13 @@ import Testing
#expect(AssistantTextParser.hasVisibleContent(in: "<think>internal</think>") == false)
#expect(AssistantTextParser.hasVisibleContent(in: "<think>internal</think>", includeThinking: true))
}
@Test func usesStableSegmentIDsAcrossRepeatedStreamingParses() {
let raw = "<think>internal</think>\n\n<final>Hello there</final>"
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)
}
}