@@ -50,6 +50,8 @@ struct MobileChatView: View {
5050 /// Re-asserts the first scroll-to-bottom while the lazy stack and composer
5151 /// geometry settle on thread entry.
5252 @State private var initialScrollTask : Task < Void , Never > ?
53+ /// Owns coalesced automatic bottom-follow while streamed content grows.
54+ @State private var autoBottomScrollTask : Task < Void , Never > ?
5355 /// Prevents repeated scheduling while the delayed initial scroll is
5456 /// waiting for the first loaded page to finish laying out.
5557 @State private var isEstablishingInitialScroll = false
@@ -94,6 +96,7 @@ struct MobileChatView: View {
9496 /// new programmatic top-pin before it has settled.
9597 @State private var canReleasePinnedTurnByScroll = false
9698 @State private var distanceFromBottom : CGFloat = 0
99+ @State private var lastAutoBottomScrollDate = Date . distantPast
97100 @State private var minimumThreadLoadElapsed = false
98101 @State private var isThreadLoadingOverlayVisible = true
99102 @State private var threadLoadingHideTask : Task < Void , Never > ?
@@ -115,6 +118,8 @@ struct MobileChatView: View {
115118 /// Approximate indicator height plus LazyVStack spacing. Cleared once the
116119 /// tail marker reports the indicator in measured geometry.
117120 private static let streamingIndicatorEstimatedHeight : CGFloat = 36
121+ /// Maximum automatic bottom-follow cadence while content streams in.
122+ private static let autoBottomScrollInterval : TimeInterval = 2
118123 private static let pinToTopAnimationDuration : Duration = . milliseconds( 320 )
119124 private static let pinToTopAnimationSeconds : Double = 0.32
120125
@@ -498,14 +503,14 @@ struct MobileChatView: View {
498503 } else if repinActiveTurnIfNeeded ( proxy: proxy) {
499504 return
500505 } else if autoScrollEnabled {
501- withAnimation { proxy. scrollTo ( Self . bottomAnchorID , anchor : . bottom ) }
506+ scrollToBottomDebounced ( proxy : proxy, reason : " lastMessage " )
502507 }
503508 }
504509 . onChange ( of: messages. last? . content) { _, _ in
505510 guard didEstablishInitialScroll else { return }
506511 if repinActiveTurnIfNeeded ( proxy: proxy) { return }
507512 guard autoScrollEnabled else { return }
508- withAnimation { proxy. scrollTo ( Self . bottomAnchorID , anchor : . bottom ) }
513+ scrollToBottomDebounced ( proxy : proxy, reason : " messageContent " )
509514 }
510515 . onChange ( of: isStreaming) { _, streaming in
511516 // Keep the newly appeared loading indicator in view.
@@ -518,7 +523,7 @@ struct MobileChatView: View {
518523 }
519524 if repinActiveTurnIfNeeded ( proxy: proxy) { return }
520525 guard autoScrollEnabled else { return }
521- withAnimation { proxy. scrollTo ( Self . bottomAnchorID , anchor : . bottom ) }
526+ scrollToBottomDebounced ( proxy : proxy, reason : " streamingStarted " )
522527 }
523528 . onChange ( of: isLoadingMore) { _, loading in
524529 guard !loading, let anchor = pendingTopAnchorID else { return }
@@ -798,9 +803,7 @@ struct MobileChatView: View {
798803 Task { @MainActor in
799804 try ? await Task . sleep ( for: . milliseconds( 16 ) )
800805 guard didEstablishInitialScroll, autoScrollEnabled else { return }
801- withAnimation ( . easeInOut( duration: 0.2 ) ) {
802- proxy. scrollTo ( Self . bottomAnchorID, anchor: . bottom)
803- }
806+ scrollToBottomDebounced ( proxy: proxy, reason: " layout " )
804807 }
805808 }
806809
@@ -883,6 +886,8 @@ struct MobileChatView: View {
883886 // The sent message round-trips through the desktop; when it comes back
884887 // it is pinned to the top. Suppress bottom-follow so the streaming
885888 // reply fills the space below the question instead of yanking past it.
889+ autoBottomScrollTask? . cancel ( )
890+ autoBottomScrollTask = nil
886891 awaitingSentUserMessage = true
887892 activeTurnUserMessageID = nil
888893 pendingIndicatorSpacerReduction = 0
@@ -938,6 +943,8 @@ struct MobileChatView: View {
938943
939944 /// Pin a freshly sent user message to the top of the viewport.
940945 private func pinSentMessageToTop( _ id: UUID , proxy: ScrollViewProxy , animated: Bool ) {
946+ autoBottomScrollTask? . cancel ( )
947+ autoBottomScrollTask = nil
941948 pinToTopTask? . cancel ( )
942949 canReleasePinnedTurnByScroll = false
943950 pinToTopTask = Task { @MainActor in
@@ -1036,6 +1043,8 @@ struct MobileChatView: View {
10361043 )
10371044 autoScrollEnabled = true
10381045 isUserDragging = false
1046+ autoBottomScrollTask? . cancel ( )
1047+ autoBottomScrollTask = nil
10391048 scrollToBottomFromButton ( proxy)
10401049 } label: {
10411050 Image ( systemName: " arrow.down " )
@@ -1063,6 +1072,7 @@ struct MobileChatView: View {
10631072 pinToTopTask? . cancel ( )
10641073 canReleasePinnedTurnByScroll = false
10651074 isPinningLatestTurnToTop = false
1075+ lastAutoBottomScrollDate = Date ( )
10661076 withAnimation ( . easeInOut( duration: 0.2 ) ) {
10671077 proxy. scrollTo ( Self . bottomAnchorID, anchor: . bottom)
10681078 }
@@ -1074,6 +1084,36 @@ struct MobileChatView: View {
10741084 }
10751085 }
10761086
1087+ /// Coalesces automatic bottom-follow while streaming content grows. Explicit
1088+ /// user actions and initial thread positioning still scroll immediately.
1089+ private func scrollToBottomDebounced( proxy: ScrollViewProxy , reason: String ) {
1090+ guard autoBottomScrollTask == nil else { return }
1091+ let elapsed = Date ( ) . timeIntervalSince ( lastAutoBottomScrollDate)
1092+ let delay = max ( 0 , Self . autoBottomScrollInterval - elapsed)
1093+ mobileChatLogger. debug (
1094+ " [AutoScroll] scheduled reason= \( reason, privacy: . public) delay= \( delay, privacy: . public) session= \( sessionID, privacy: . public) "
1095+ )
1096+ autoBottomScrollTask = Task { @MainActor in
1097+ if delay > 0 {
1098+ try ? await Task . sleep ( nanoseconds: UInt64 ( delay * 1_000_000_000 ) )
1099+ }
1100+ guard !Task. isCancelled else { return }
1101+ autoBottomScrollTask = nil
1102+ guard didEstablishInitialScroll,
1103+ autoScrollEnabled,
1104+ !isUserDragging,
1105+ !isPinningLatestTurnToTop
1106+ else { return }
1107+ lastAutoBottomScrollDate = Date ( )
1108+ mobileChatLogger. debug (
1109+ " [AutoScroll] fired reason= \( reason, privacy: . public) session= \( sessionID, privacy: . public) "
1110+ )
1111+ withAnimation ( . easeInOut( duration: 0.2 ) ) {
1112+ proxy. scrollTo ( Self . bottomAnchorID, anchor: . bottom)
1113+ }
1114+ }
1115+ }
1116+
10771117 // MARK: - Queued preview pill
10781118
10791119 private var queuedPreviewPill : some View {
0 commit comments