From d2f065d771e4e90f47e91c37625b13a4bce8db50 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:30:20 +0800 Subject: [PATCH 1/3] fix: make stream-end scroll re-assertion non-animated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a thread finished streaming, the streaming→settled row handoff reflowed the lazy list and snapped the scroll offset; an animated correction on top of that read as a visible "scroll up, then glide to the end" jump. Thread a `scrollToBottomAnimated` intent through MessageListView → ChatTranscriptList → MessageList so the stream-end re-assertion snaps to the bottom instantly. Live follow-scrolls during streaming stay animated (separate path), and the new param defaults to true so no other call sites change behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/MessageList/MessageList.swift | 5 ++++- .../RxCodeChatKit/ChatTranscriptList.swift | 4 ++++ .../RxCodeChatKit/MessageListView.swift | 19 +++++++++++++++---- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/Packages/Sources/MessageList/MessageList.swift b/Packages/Sources/MessageList/MessageList.swift index 3e559217..b070c277 100644 --- a/Packages/Sources/MessageList/MessageList.swift +++ b/Packages/Sources/MessageList/MessageList.swift @@ -19,6 +19,7 @@ public struct MessageList: View { private let messages: [Message] private let isStreaming: Bool private let shouldScrollToBottom: Bool + private let scrollToBottomAnimated: Bool @Binding private var isAtBottom: Bool private let hasMorePrevious: () -> Bool private let hasMore: () -> Bool @@ -49,6 +50,7 @@ public struct MessageList: View { messages: [Message], isStreaming: Bool = false, shouldScrollToBottom: Bool = false, + scrollToBottomAnimated: Bool = true, isAtBottom: Binding = .constant(true), hasMorePrevious: @escaping () -> Bool = { false }, hasMore: @escaping () -> Bool = { false }, @@ -60,6 +62,7 @@ public struct MessageList: View { self.messages = messages self.isStreaming = isStreaming self.shouldScrollToBottom = shouldScrollToBottom + self.scrollToBottomAnimated = scrollToBottomAnimated self._isAtBottom = isAtBottom self.hasMorePrevious = hasMorePrevious self.hasMore = hasMore @@ -138,7 +141,7 @@ public struct MessageList: View { guard shouldScroll else { return } guard !pinning.isPinningUserMessage else { return } anchor.resetToBottom() - scrollToBottom(proxy: proxy, animated: true) + scrollToBottom(proxy: proxy, animated: scrollToBottomAnimated) } .onChange(of: isStreaming) { oldValue, newValue in if !newValue { diff --git a/Packages/Sources/RxCodeChatKit/ChatTranscriptList.swift b/Packages/Sources/RxCodeChatKit/ChatTranscriptList.swift index bbd0842b..d135e7f6 100644 --- a/Packages/Sources/RxCodeChatKit/ChatTranscriptList.swift +++ b/Packages/Sources/RxCodeChatKit/ChatTranscriptList.swift @@ -72,6 +72,7 @@ public struct ChatTranscriptList: View { private let items: [ChatTranscriptListItem] private let isStreaming: Bool private let shouldScrollToBottom: Bool + private let scrollToBottomAnimated: Bool @Binding private var isAtBottom: Bool private let hasMorePrevious: () -> Bool private let loadMorePrevious: (() async throws -> Void)? @@ -84,6 +85,7 @@ public struct ChatTranscriptList: View { items: [ChatTranscriptListItem], isStreaming: Bool = false, shouldScrollToBottom: Bool = false, + scrollToBottomAnimated: Bool = true, isAtBottom: Binding = .constant(true), hasMorePrevious: @escaping () -> Bool = { false }, loadMorePrevious: (() async throws -> Void)? = nil, @@ -95,6 +97,7 @@ public struct ChatTranscriptList: View { self.items = items self.isStreaming = isStreaming self.shouldScrollToBottom = shouldScrollToBottom + self.scrollToBottomAnimated = scrollToBottomAnimated self._isAtBottom = isAtBottom self.hasMorePrevious = hasMorePrevious self.loadMorePrevious = loadMorePrevious @@ -109,6 +112,7 @@ public struct ChatTranscriptList: View { messages: items, isStreaming: isStreaming, shouldScrollToBottom: shouldScrollToBottom, + scrollToBottomAnimated: scrollToBottomAnimated, isAtBottom: $isAtBottom, hasMorePrevious: hasMorePrevious, loadMorePrevious: loadMorePrevious, diff --git a/Packages/Sources/RxCodeChatKit/MessageListView.swift b/Packages/Sources/RxCodeChatKit/MessageListView.swift index cdfc6a0f..1a91cdee 100644 --- a/Packages/Sources/RxCodeChatKit/MessageListView.swift +++ b/Packages/Sources/RxCodeChatKit/MessageListView.swift @@ -50,6 +50,11 @@ struct MessageListView: View { @State private var activeTurnMaxMeasuredHeight: CGFloat = 0 @State private var lastBottomScrollDate = Date.distantPast @State private var shouldScrollToBottom = false + /// Whether the next `shouldScrollToBottom` request should animate. The + /// stream-end re-assertion sets this false: the streaming→settled row handoff + /// reflows the lazy list and snaps the offset, and an animated correction on + /// top of that reads as a visible "scroll up, then glide to the end" jump. + @State private var scrollToBottomAnimated = true @State private var isAtBottom = true @State private var scrollRequestTask: Task? @@ -139,6 +144,7 @@ struct MessageListView: View { items: transcriptItems, isStreaming: chatBridge.isStreaming, shouldScrollToBottom: shouldScrollToBottom, + scrollToBottomAnimated: scrollToBottomAnimated, isAtBottom: $isAtBottom ) { accessory in transcriptAccessory(accessory) @@ -276,7 +282,11 @@ struct MessageListView: View { // still following the bottom before the handoff. if wasAtBottom { anchor.resetToBottom() - requestScrollToBottom() + // Non-animated: the row handoff above already reflowed the list and + // snapped the offset. An animated correction on top of that reads as a + // visible "scroll up, then glide back to the end" jump; snapping puts + // the bottom back in the same beat as the reflow. + requestScrollToBottom(animated: false) } } @@ -370,9 +380,10 @@ struct MessageListView: View { activeTurnMaxMeasuredHeight = 0 } - private func requestScrollToBottom() { + private func requestScrollToBottom(animated: Bool = true) { scrollRequestTask?.cancel() shouldScrollToBottom = false + scrollToBottomAnimated = animated scrollRequestTask = Task { @MainActor in try? await Task.sleep(for: .milliseconds(10)) guard !Task.isCancelled else { return } @@ -380,13 +391,13 @@ struct MessageListView: View { } } - private func requestScrollToBottomIfAtBottom(_ atBottom: Bool? = nil) { + private func requestScrollToBottomIfAtBottom(_ atBottom: Bool? = nil, animated: Bool = true) { let shouldFollowBottom = atBottom ?? isAtBottom guard MessageListViewScrollPolicy.shouldRequestLiveBottomScroll(wasAtBottom: shouldFollowBottom) else { logScrollState("scrollToBottom.skippedNotAtBottom") return } - requestScrollToBottom() + requestScrollToBottom(animated: animated) } /// Returns the last consecutive assistant sequence (including streaming turn) while streaming. From 63c4f78761fbb8407eab90314298409907793316 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:51:06 +0800 Subject: [PATCH 2/3] perf: reduce scroll churn and markdown re-parsing during streaming - Throttle streaming follow-scrolls through the existing scheduler so they collapse to ~1 scroll per interval instead of one per token, and coalesce scroll-to-bottom requests by animation intent. - Add MarkdownParseCache to memoize settled (non-streaming) markdown parses; live/streaming text is parsed directly and never cached to avoid retaining near-full response copies. - Coalesce streaming markdown re-parses to ~60fps via a leading-edge throttle. - Tune scroll/pin animation curves (spring) and timing constants; refine message fade transitions. - Add temporary ScrollToBottomDiag instrumentation, separating actual scroll calls from skipped requests. - Switch RxCode target code signing to Automatic. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/MessageList/MessageList.swift | 88 +++++++++++--- .../RxCodeChatKit/ChatTranscriptList.swift | 6 +- .../RxCodeChatKit/MessageListView.swift | 52 ++++++-- .../Sources/RxCodeMarkdown/MarkdownView.swift | 111 ++++++++++++++++-- RxCode.xcodeproj/project.pbxproj | 5 +- 5 files changed, 228 insertions(+), 34 deletions(-) diff --git a/Packages/Sources/MessageList/MessageList.swift b/Packages/Sources/MessageList/MessageList.swift index b070c277..75f68ce5 100644 --- a/Packages/Sources/MessageList/MessageList.swift +++ b/Packages/Sources/MessageList/MessageList.swift @@ -1,5 +1,42 @@ import Foundation import SwiftUI +import os + +/// Temporary diagnostics for the "list scrolls a lot when streaming finishes" +/// report. Counts every real `proxy.scrollTo(bottomAnchor)` call and logs it +/// with a millisecond timestamp + reason so we can see how many fire and from +/// which trigger. Remove once the scroll-churn is understood. +@MainActor +enum ScrollToBottomDiag { + /// Counts only real `proxy.scrollTo(bottomAnchor)` calls — the answer to + /// "how many scroll-to-bottom API calls were triggered". + private static var count = 0 + /// Counts requests that were *asked for* but bailed before any scroll API + /// call (spacer absorbed the growth, or a pin owns the position). Kept on a + /// separate counter/label so it never inflates the actual-call count above. + private static var skippedCount = 0 + private static let log = Logger(subsystem: "com.claudework", category: "ScrollToBottomDiag") + private static let formatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "HH:mm:ss.SSS" + return f + }() + + /// Log an actual `proxy.scrollTo` call. Invoke immediately before the real + /// scroll so the count stays an accurate tally of scroll API invocations. + static func record(_ reason: String, animated: Bool, streaming: Bool) { + count += 1 + let ts = formatter.string(from: Date()) + log.info("[ScrollToBottom] #\(count, privacy: .public) \(ts, privacy: .public) reason=\(reason, privacy: .public) animated=\(animated, privacy: .public) streaming=\(streaming, privacy: .public)") + } + + /// Log a scroll request that was requested but skipped (no scroll API call). + static func recordSkipped(_ reason: String, animated: Bool, streaming: Bool) { + skippedCount += 1 + let ts = formatter.string(from: Date()) + log.info("[ScrollToBottomSkipped] #\(skippedCount, privacy: .public) \(ts, privacy: .public) reason=\(reason, privacy: .public) animated=\(animated, privacy: .public) streaming=\(streaming, privacy: .public)") + } +} public protocol MessageListItem: Identifiable, Sendable where ID: Hashable & Sendable { var isUserMessage: Bool { get } @@ -134,14 +171,31 @@ public struct MessageList: View { } .task { if shouldScrollToBottom { - scrollToBottom(proxy: proxy, animated: false) + scrollToBottom(proxy: proxy, animated: false, reason: "task.initial") } } .onChange(of: shouldScrollToBottom) { _, shouldScroll in guard shouldScroll else { return } - guard !pinning.isPinningUserMessage else { return } + guard !pinning.isPinningUserMessage else { + ScrollToBottomDiag.recordSkipped("onChangeShouldScroll.skippedPinning", animated: scrollToBottomAnimated, streaming: isStreaming) + return + } + // While streaming, the content `onChange` toggles this once per + // token (≈4/sec). Scrolling immediately on each toggle stacks a + // fresh spring animation per token — the visible "scrolls a lot" + // churn. Funnel the streaming follow through the same throttle the + // message-change path uses (`scheduleScrollToBottom`, gated to one + // pending task and rate-limited to `streamingBottomScrollInterval`) + // so it collapses to ≈1 scroll per interval. The animation is + // preserved; only the cadence changes. Non-streaming toggles + // (session open, new send, the non-animated stream-end re-assert) + // keep the immediate path so they stay snappy and exact. + if isStreaming { + scheduleScrollToBottom(proxy: proxy) + return + } anchor.resetToBottom() - scrollToBottom(proxy: proxy, animated: scrollToBottomAnimated) + scrollToBottom(proxy: proxy, animated: scrollToBottomAnimated, reason: "onChangeShouldScroll") } .onChange(of: isStreaming) { oldValue, newValue in if !newValue { @@ -362,16 +416,20 @@ public struct MessageList: View { isAtBottom = value } - private func scrollToBottom(proxy: ScrollViewProxy, animated: Bool) { + private func scrollToBottom(proxy: ScrollViewProxy, animated: Bool, reason: String = "scrollToBottom") { // While reserved spacing exists below the latest turn, the content already fits // in the viewport — a follow/auto scroll would only pull the empty reserved // space into view and shove the turn around. Only scroll once the turn has // outgrown the viewport (no spacing left). The one scroll that is allowed to // move into the reserved area is the initial turn placement, which goes through // `scrollLatestTurnIntoView` (a direct `proxy.scrollTo`), not this path. - guard pinTailSpacerHeight <= 0 else { return } + guard pinTailSpacerHeight <= 0 else { + ScrollToBottomDiag.recordSkipped("\(reason).skippedSpacer", animated: animated, streaming: isStreaming) + return + } + ScrollToBottomDiag.record(reason, animated: animated, streaming: isStreaming) if animated { - withAnimation(.easeInOut(duration: MessageListConstants.scrollAnimationSeconds)) { + withAnimation(.spring(duration: MessageListConstants.scrollAnimationSeconds, bounce: 0)) { proxy.scrollTo(MessageListConstants.bottomAnchorID, anchor: .bottom) } } else { @@ -392,7 +450,7 @@ public struct MessageList: View { if isStreaming { lastStreamingBottomScrollDate = Date() } - scrollToBottom(proxy: proxy, animated: true) + scrollToBottom(proxy: proxy, animated: true, reason: "scheduled") } } @@ -424,7 +482,8 @@ public struct MessageList: View { guard !Task.isCancelled else { return } if animated { - withAnimation(.easeInOut(duration: MessageListConstants.pinAnimationSeconds)) { + ScrollToBottomDiag.record("scrollLatestTurnIntoView.animated", animated: true, streaming: isStreaming) + withAnimation(.spring(duration: MessageListConstants.pinAnimationSeconds, bounce: 0.05)) { proxy.scrollTo(MessageListConstants.bottomAnchorID, anchor: .bottom) } try? await Task.sleep(for: MessageListConstants.pinAnimationDuration) @@ -433,8 +492,9 @@ public struct MessageList: View { // Re-assert across several frames so the position tracks the tail spacer // as it settles to its final size (the turn height is measured a frame or // two after the freshly-added content lays out). - for _ in 0..<8 { + for attempt in 0..<8 { guard !Task.isCancelled else { return } + ScrollToBottomDiag.record("scrollLatestTurnIntoView.settle[\(attempt)]", animated: false, streaming: isStreaming) var transaction = Transaction() transaction.animation = nil withTransaction(transaction) { @@ -596,10 +656,10 @@ private nonisolated enum MessageListConstants { static let loadThreshold: CGFloat = 96 static let minimumPinnedTailSpacing: CGFloat = 16 static let userScrollDownDelta: CGFloat = 4 - static let layoutSettleDelayNanoseconds: UInt64 = 16_000_000 - static let streamingBottomScrollInterval: TimeInterval = 2 + static let layoutSettleDelayNanoseconds: UInt64 = 8_000_000 + static let streamingBottomScrollInterval: TimeInterval = 1.5 static let loadMoreCooldownSeconds: TimeInterval = 1 - static let scrollAnimationSeconds: Double = 0.24 - static let pinAnimationDuration: Duration = .milliseconds(320) - static let pinAnimationSeconds: Double = 0.32 + static let scrollAnimationSeconds: Double = 0.18 + static let pinAnimationDuration: Duration = .milliseconds(250) + static let pinAnimationSeconds: Double = 0.25 } diff --git a/Packages/Sources/RxCodeChatKit/ChatTranscriptList.swift b/Packages/Sources/RxCodeChatKit/ChatTranscriptList.swift index d135e7f6..ea739b17 100644 --- a/Packages/Sources/RxCodeChatKit/ChatTranscriptList.swift +++ b/Packages/Sources/RxCodeChatKit/ChatTranscriptList.swift @@ -141,7 +141,11 @@ public struct ChatTranscriptList: View { private func messageFadeTransition(role: Role) -> AnyTransition { let anchor: UnitPoint = role == .user ? .bottomTrailing : .bottomLeading - return .opacity.combined(with: .scale(scale: 0.97, anchor: anchor)) + return .asymmetric( + insertion: .opacity.combined(with: .scale(scale: 0.98, anchor: anchor)) + .animation(.spring(duration: 0.25, bounce: 0.1)), + removal: .opacity.animation(.easeOut(duration: 0.15)) + ) } private static var defaultRowPadding: EdgeInsets { diff --git a/Packages/Sources/RxCodeChatKit/MessageListView.swift b/Packages/Sources/RxCodeChatKit/MessageListView.swift index 1a91cdee..bdc60625 100644 --- a/Packages/Sources/RxCodeChatKit/MessageListView.swift +++ b/Packages/Sources/RxCodeChatKit/MessageListView.swift @@ -59,6 +59,18 @@ struct MessageListView: View { @State private var scrollRequestTask: Task? private static let log = Logger(subsystem: "com.claudework", category: "MessageListView") + /// Temporary diagnostics: counts scroll-to-bottom *requests* (pre-coalesce) + /// so we can correlate per-token request churn with the actual scrollTo calls + /// logged in MessageList. Remove once scroll-churn is understood. + private static var scrollRequestCount = 0 + private static let diagTimestampFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "HH:mm:ss.SSS" + return f + }() + private static func diagTimestamp() -> String { + diagTimestampFormatter.string(from: Date()) + } private static let bottomAnchorID = "message-list-bottom-anchor" private static let endOfScreenAnchorID = "message-list-end-of-screen" private static let userScrollDownDelta: CGFloat = 4 @@ -217,7 +229,7 @@ struct MessageListView: View { settleScrollTask?.cancel() readyTask?.cancel() pinToTopTask?.cancel() - scrollRequestTask?.cancel() + cancelScrollRequest() activeTurnUserMessageID = nil isPinningLatestTurnToTop = false canReleasePinnedTurnByScroll = false @@ -372,7 +384,7 @@ struct MessageListView: View { scrollTask = nil settleScrollTask?.cancel() pinToTopTask?.cancel() - scrollRequestTask?.cancel() + cancelScrollRequest() activeTurnUserMessageID = nil isPinningLatestTurnToTop = false canReleasePinnedTurnByScroll = false @@ -381,16 +393,37 @@ struct MessageListView: View { } private func requestScrollToBottom(animated: Bool = true) { - scrollRequestTask?.cancel() + // Coalesce: streaming fires this once per token via the `content` + // onChange. Cancelling and reallocating the task — plus toggling + // `shouldScrollToBottom` false→true — on every token is pure per-token + // churn. If a request with the same animation intent is already in + // flight, let it land instead of rebuilding it; only a differing intent + // (e.g. the non-animated stream-end re-assert) supersedes it. + Self.scrollRequestCount += 1 + let coalesced = scrollRequestTask != nil && scrollToBottomAnimated == animated + Self.log.info("[ScrollRequest] #\(Self.scrollRequestCount, privacy: .public) \(Self.diagTimestamp(), privacy: .public) animated=\(animated, privacy: .public) coalesced=\(coalesced, privacy: .public) streaming=\(chatBridge.isStreaming, privacy: .public)") + if scrollRequestTask != nil, scrollToBottomAnimated == animated { + return + } + cancelScrollRequest() shouldScrollToBottom = false scrollToBottomAnimated = animated scrollRequestTask = Task { @MainActor in try? await Task.sleep(for: .milliseconds(10)) guard !Task.isCancelled else { return } + scrollRequestTask = nil shouldScrollToBottom = true } } + /// Cancels any in-flight scroll request and clears the handle so the + /// coalescing guard in `requestScrollToBottom` doesn't see a dead task as + /// "still pending" and block future requests. + private func cancelScrollRequest() { + scrollRequestTask?.cancel() + scrollRequestTask = nil + } + private func requestScrollToBottomIfAtBottom(_ atBottom: Bool? = nil, animated: Bool = true) { let shouldFollowBottom = atBottom ?? isAtBottom guard MessageListViewScrollPolicy.shouldRequestLiveBottomScroll(wasAtBottom: shouldFollowBottom) else { @@ -676,7 +709,7 @@ struct StreamingMessageView: View { } } } - .animation(.easeOut(duration: 0.28), value: activeMessages.map(\.id)) + .animation(.spring(duration: 0.22, bounce: 0.05), value: activeMessages.map(\.id)) .onChange(of: messages.count) { _, _ in onStructureChanged() } @@ -684,7 +717,8 @@ struct StreamingMessageView: View { private func streamFadeTransition(role: Role) -> AnyTransition { let anchor: UnitPoint = role == .user ? .bottomTrailing : .bottomLeading - let insertion: AnyTransition = .opacity.combined(with: .scale(scale: 0.97, anchor: anchor)) + let insertion: AnyTransition = .opacity.combined(with: .scale(scale: 0.98, anchor: anchor)) + .animation(.spring(duration: 0.22, bounce: 0.08)) return .asymmetric(insertion: insertion, removal: .identity) } @@ -823,7 +857,7 @@ private struct InlineElapsedTimeView: View { /// Replaces the previous card with "Thinking..." / "Generating response..." text. struct AnimatedDotsView: View { @State private var phase: Int = 0 - private let timer = Timer.publish(every: 0.18, on: .main, in: .common).autoconnect() + private let timer = Timer.publish(every: 0.22, on: .main, in: .common).autoconnect() var body: some View { HStack(spacing: 4) { @@ -831,9 +865,9 @@ struct AnimatedDotsView: View { Circle() .fill(ClaudeTheme.textTertiary) .frame(width: 6, height: 6) - .opacity(phase == i ? 1.0 : 0.3) - .scaleEffect(phase == i ? 1.0 : 0.85) - .animation(.easeInOut(duration: 0.25), value: phase) + .opacity(phase == i ? 1.0 : 0.35) + .scaleEffect(phase == i ? 1.0 : 0.8) + .animation(.spring(duration: 0.3, bounce: 0.2), value: phase) } } .onReceive(timer) { _ in diff --git a/Packages/Sources/RxCodeMarkdown/MarkdownView.swift b/Packages/Sources/RxCodeMarkdown/MarkdownView.swift index babe2a8f..f1850aae 100644 --- a/Packages/Sources/RxCodeMarkdown/MarkdownView.swift +++ b/Packages/Sources/RxCodeMarkdown/MarkdownView.swift @@ -70,10 +70,67 @@ public final class MarkdownImageCache: @unchecked Sendable { public init() {} } +/// Memoization cache for parsed markdown documents of *settled* messages. +/// +/// `MarkdownDocumentParser.parse` is a pure function of its input but is +/// comparatively expensive (full block tree + inline `AttributedString` +/// construction). SwiftUI re-evaluates a view's `body` far more often than the +/// underlying text changes — every sibling-row update, scroll tick, or window +/// resize re-runs it — so an unguarded parse in `body` re-does that work for +/// settled messages that haven't changed at all. Keying on the exact string +/// makes those re-renders cost a lookup instead of a re-parse. +/// +/// Streaming/coalesced text is deliberately **never** cached: it is a sequence +/// of unique, growing full-message snapshots, so caching it would retain dozens +/// of near-full copies of the response (plus their parsed trees) for the app's +/// lifetime — memory that reads as a leak after a long stream. Live text is +/// parsed directly via `cacheable: false`. Settled documents above +/// `maxCacheableTextBytes` are also parsed on demand rather than retained, and +/// the underlying `NSCache` is cost-limited so it evicts under memory pressure. +final class MarkdownParseCache: @unchecked Sendable { + static let shared = MarkdownParseCache() + + private final class Box { + let document: MarkdownDocument + init(_ document: MarkdownDocument) { self.document = document } + } + + private let cache = NSCache() + /// Settled documents whose source exceeds this size are parsed on demand + /// rather than cached — avoids holding near-full copies of very large + /// outputs or logs indefinitely. + private let maxCacheableTextBytes = 16 * 1024 + + private init() { + cache.totalCostLimit = 4 * 1024 * 1024 // ~4MB of source text + } + + /// Returns the parsed document for `text`. The cache is consulted and + /// populated only when `cacheable` is true (settled, reasonably-sized + /// messages); live/streaming text is always parsed directly and never + /// stored. + func document(for text: String, cacheable: Bool) -> MarkdownDocument { + let cost = text.utf8.count + guard cacheable, cost <= maxCacheableTextBytes else { + return MarkdownDocumentParser.parse(text) + } + let key = text as NSString + if let box = cache.object(forKey: key) { + return box.document + } + let parsed = MarkdownDocumentParser.parse(text) + cache.setObject(Box(parsed), forKey: key, cost: cost) + return parsed + } +} + public struct MarkdownView: View { public typealias LinkHandler = (URL) -> OpenURLAction.Result - private static let newTextFadeDuration: Duration = .milliseconds(650) + private static let newTextFadeDuration: Duration = .milliseconds(800) + /// Maximum cadence at which streaming text is handed to the (expensive) + /// markdown parse. Caps re-parsing at ~60fps instead of once per token. + private static let streamCoalesceInterval: Duration = .milliseconds(16) private let text: String private let showsTrailingCursor: Bool @@ -88,6 +145,13 @@ public struct MarkdownView: View { @State private var observedText: String @State private var fadeSegments: [MarkdownFadeSegment] = [] @State private var fadeTasks: [UUID: Task] = [:] + /// The text actually fed to the parser. Lags `text` by at most + /// `streamCoalesceInterval` so a burst of streaming token deltas collapses + /// into a single re-parse per frame instead of one per token. + @State private var displayedText: String + /// Latest text seen during an active coalescing window, applied when it ends. + @State private var pendingText: String? + @State private var coalesceTask: Task? public init( text: String, @@ -110,6 +174,7 @@ public struct MarkdownView: View { self.imageCache = imageCache self.onOpenLink = onOpenLink _observedText = State(initialValue: text) + _displayedText = State(initialValue: text) } public var body: some View { @@ -118,8 +183,12 @@ public struct MarkdownView: View { @ViewBuilder private var content: some View { + // A live message (streaming cursor and/or fade-in) produces unique, + // growing snapshots — never cache those, or the cache fills with + // near-full copies of the response. Only settled text is memoized. + let isLiveMessage = showsTrailingCursor || fadeNewText let view = MarkdownDocumentView( - document: MarkdownDocumentParser.parse(renderedText), + document: MarkdownParseCache.shared.document(for: renderedText, cacheable: !isLiveMessage), baseURL: baseURL, style: style, imageCache: imageCache, @@ -128,9 +197,10 @@ public struct MarkdownView: View { .textSelection(.enabled) .frame(maxWidth: expandsHorizontally ? .infinity : nil, alignment: .leading) .onChange(of: text) { _, newText in - updateFadeState(for: newText) + coalesceDisplayUpdate(to: newText) } .onDisappear { + coalesceTask?.cancel() cancelFadeTasks() } @@ -144,13 +214,38 @@ public struct MarkdownView: View { } private var renderedText: String { - if showsTrailingCursor && isCursorVisible { - text + "\u{2009}\u{25CF}" - } else { - text + displayedText + } + + /// Leading-edge throttle for streaming text. + /// + /// The first change renders immediately, so a settled message (a single + /// update) has no added latency. Subsequent changes inside the cooldown + /// window are collapsed to the latest value and flushed once when the + /// window ends. Without this, `MarkdownDocumentParser.parse` runs on every + /// streamed token — O(n) work per token, O(n²) over a message — which + /// dominates allocation churn and stalls the main thread mid-stream. + private func coalesceDisplayUpdate(to newText: String) { + guard coalesceTask == nil else { + pendingText = newText + return + } + applyDisplayedText(newText) + coalesceTask = Task { @MainActor in + try? await Task.sleep(for: Self.streamCoalesceInterval) + coalesceTask = nil + if let pending = pendingText { + pendingText = nil + coalesceDisplayUpdate(to: pending) + } } } + private func applyDisplayedText(_ newText: String) { + displayedText = newText + updateFadeState(for: newText) + } + private func updateFadeState(for newText: String) { let oldText = observedText observedText = newText @@ -170,7 +265,7 @@ public struct MarkdownView: View { let segment = MarkdownFadeSegment(range: insertedRange, opacity: 0) fadeSegments.append(segment) fadeTasks[segment.id] = Task { @MainActor in - try? await Task.sleep(for: .milliseconds(16)) + try? await Task.sleep(for: .milliseconds(8)) guard !Task.isCancelled else { return } withAnimation(.easeOut(duration: Self.newTextFadeDuration.timeInterval)) { setFadeSegmentOpacity(id: segment.id, opacity: 1) diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index 707143e8..60f45d9c 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -1409,7 +1409,8 @@ ASSETCATALOG_COMPILER_APPICON_NAME = icon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = RxCode/RxCode.entitlements; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_TEAM = P9KK452K8P; @@ -1428,7 +1429,7 @@ MARKETING_VERSION = 1.8.0; PRODUCT_BUNDLE_IDENTIFIER = com.rxlab.RxCode; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = RxCode; + PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; From f84e178741ee36f5c717a03c8c1c0bcd869773c4 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:19:14 +0800 Subject: [PATCH 3/3] fix(ci): restore manual signing + RxCode provisioning profile for archive The previous perf commit accidentally bundled a signing-config change into the RxCode target's Release configuration: CODE_SIGN_STYLE was flipped to Automatic and PROVISIONING_PROFILE_SPECIFIER was emptied. The CI archive step signs manually (CODE_SIGN_STYLE=Manual on the command line) and relies on PROVISIONING_PROFILE_SPECIFIER=RxCode in project.pbxproj to scope the provisioning profile (which carries the Associated Domains capability) to this target. With the specifier emptied, the archive could not find a profile with Associated Domains and failed: "RxCode" requires a provisioning profile with the Associated Domains feature. Restore CODE_SIGN_STYLE=Manual and PROVISIONING_PROFILE_SPECIFIER=RxCode to match the last-passing build. Co-Authored-By: Claude Opus 4.8 (1M context) --- RxCode.xcodeproj/project.pbxproj | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index 60f45d9c..707143e8 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -1409,8 +1409,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = icon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = RxCode/RxCode.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_TEAM = P9KK452K8P; @@ -1429,7 +1428,7 @@ MARKETING_VERSION = 1.8.0; PRODUCT_BUNDLE_IDENTIFIER = com.rxlab.RxCode; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE_SPECIFIER = RxCode; REGISTER_APP_GROUPS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;