Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 77 additions & 14 deletions Packages/Sources/MessageList/MessageList.swift
Original file line number Diff line number Diff line change
@@ -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 }
Expand All @@ -19,6 +56,7 @@ public struct MessageList<Message: MessageListItem, RowContent: View>: 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
Expand Down Expand Up @@ -49,6 +87,7 @@ public struct MessageList<Message: MessageListItem, RowContent: View>: View {
messages: [Message],
isStreaming: Bool = false,
shouldScrollToBottom: Bool = false,
scrollToBottomAnimated: Bool = true,
isAtBottom: Binding<Bool> = .constant(true),
hasMorePrevious: @escaping () -> Bool = { false },
hasMore: @escaping () -> Bool = { false },
Expand All @@ -60,6 +99,7 @@ public struct MessageList<Message: MessageListItem, RowContent: View>: View {
self.messages = messages
self.isStreaming = isStreaming
self.shouldScrollToBottom = shouldScrollToBottom
self.scrollToBottomAnimated = scrollToBottomAnimated
self._isAtBottom = isAtBottom
self.hasMorePrevious = hasMorePrevious
self.hasMore = hasMore
Expand Down Expand Up @@ -131,14 +171,31 @@ public struct MessageList<Message: MessageListItem, RowContent: View>: 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: true)
scrollToBottom(proxy: proxy, animated: scrollToBottomAnimated, reason: "onChangeShouldScroll")
}
.onChange(of: isStreaming) { oldValue, newValue in
if !newValue {
Expand Down Expand Up @@ -359,16 +416,20 @@ public struct MessageList<Message: MessageListItem, RowContent: View>: 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 {
Expand All @@ -389,7 +450,7 @@ public struct MessageList<Message: MessageListItem, RowContent: View>: View {
if isStreaming {
lastStreamingBottomScrollDate = Date()
}
scrollToBottom(proxy: proxy, animated: true)
scrollToBottom(proxy: proxy, animated: true, reason: "scheduled")
}
}

Expand Down Expand Up @@ -421,7 +482,8 @@ public struct MessageList<Message: MessageListItem, RowContent: View>: 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)
Expand All @@ -430,8 +492,9 @@ public struct MessageList<Message: MessageListItem, RowContent: View>: 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) {
Expand Down Expand Up @@ -593,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
}
10 changes: 9 additions & 1 deletion Packages/Sources/RxCodeChatKit/ChatTranscriptList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ public struct ChatTranscriptList<AccessoryContent: View>: 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)?
Expand All @@ -84,6 +85,7 @@ public struct ChatTranscriptList<AccessoryContent: View>: View {
items: [ChatTranscriptListItem],
isStreaming: Bool = false,
shouldScrollToBottom: Bool = false,
scrollToBottomAnimated: Bool = true,
isAtBottom: Binding<Bool> = .constant(true),
hasMorePrevious: @escaping () -> Bool = { false },
loadMorePrevious: (() async throws -> Void)? = nil,
Expand All @@ -95,6 +97,7 @@ public struct ChatTranscriptList<AccessoryContent: View>: View {
self.items = items
self.isStreaming = isStreaming
self.shouldScrollToBottom = shouldScrollToBottom
self.scrollToBottomAnimated = scrollToBottomAnimated
self._isAtBottom = isAtBottom
self.hasMorePrevious = hasMorePrevious
self.loadMorePrevious = loadMorePrevious
Expand All @@ -109,6 +112,7 @@ public struct ChatTranscriptList<AccessoryContent: View>: View {
messages: items,
isStreaming: isStreaming,
shouldScrollToBottom: shouldScrollToBottom,
scrollToBottomAnimated: scrollToBottomAnimated,
isAtBottom: $isAtBottom,
hasMorePrevious: hasMorePrevious,
loadMorePrevious: loadMorePrevious,
Expand Down Expand Up @@ -137,7 +141,11 @@ public struct ChatTranscriptList<AccessoryContent: View>: 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 {
Expand Down
71 changes: 58 additions & 13 deletions Packages/Sources/RxCodeChatKit/MessageListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,27 @@ 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<Void, Never>?

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
Expand Down Expand Up @@ -139,6 +156,7 @@ struct MessageListView: View {
items: transcriptItems,
isStreaming: chatBridge.isStreaming,
shouldScrollToBottom: shouldScrollToBottom,
scrollToBottomAnimated: scrollToBottomAnimated,
isAtBottom: $isAtBottom
) { accessory in
transcriptAccessory(accessory)
Expand Down Expand Up @@ -211,7 +229,7 @@ struct MessageListView: View {
settleScrollTask?.cancel()
readyTask?.cancel()
pinToTopTask?.cancel()
scrollRequestTask?.cancel()
cancelScrollRequest()
activeTurnUserMessageID = nil
isPinningLatestTurnToTop = false
canReleasePinnedTurnByScroll = false
Expand Down Expand Up @@ -276,7 +294,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)
}
}

Expand Down Expand Up @@ -362,31 +384,53 @@ struct MessageListView: View {
scrollTask = nil
settleScrollTask?.cancel()
pinToTopTask?.cancel()
scrollRequestTask?.cancel()
cancelScrollRequest()
activeTurnUserMessageID = nil
isPinningLatestTurnToTop = false
canReleasePinnedTurnByScroll = false
pendingIndicatorSpacerReduction = 0
activeTurnMaxMeasuredHeight = 0
}

private func requestScrollToBottom() {
scrollRequestTask?.cancel()
private func requestScrollToBottom(animated: Bool = true) {
// 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
}
}

private func requestScrollToBottomIfAtBottom(_ atBottom: Bool? = nil) {
/// 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 {
logScrollState("scrollToBottom.skippedNotAtBottom")
return
}
requestScrollToBottom()
requestScrollToBottom(animated: animated)
}

/// Returns the last consecutive assistant sequence (including streaming turn) while streaming.
Expand Down Expand Up @@ -665,15 +709,16 @@ 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()
}
}

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

Expand Down Expand Up @@ -812,17 +857,17 @@ 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) {
ForEach(0..<3, id: \.self) { i in
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
Expand Down
Loading
Loading