@@ -5,33 +5,32 @@ import os
55
66#if os(macOS)
77
8- /// Message scroll area — extracted from ChatView to isolate @Observable dependencies on `messages`.
8+ /// Message scroll area — extracted from ChatView to isolate @Observable
9+ /// dependencies on `messages`.
10+ ///
11+ /// Behavior: the transcript stays anchored to the bottom. A freshly opened
12+ /// thread, a new send, and a streaming response all keep the latest message in
13+ /// view. `AutoScrollAnchor` releases that anchor only when the user deliberately
14+ /// scrolls up; `scrollPhase` keeps auto-scroll from fighting an active drag.
915struct MessageListView : View {
1016 @Environment ( ChatBridge . self) private var chatBridge
1117 @Environment ( WindowState . self) private var windowState
1218 @State private var settledItems : [ ChatMessage ] = [ ]
19+ /// Owns the debounced content-growth scroll.
1320 @State private var scrollTask : Task < Void , Never > ?
14- /// Owns the post-stream "pin to bottom" sweep. Kept separate from
15- /// `scrollTask` so a concurrent `scrollToBottomDebounced()` — driven by
16- /// scroll-geometry changes during the same handoff — can't cancel it.
21+ /// Owns the multi-frame "settle at the bottom" sweep used on session open
22+ /// and the streaming→settled handoff. Separate from `scrollTask` so a
23+ /// concurrent `scrollToBottomDebounced()` can't cancel it.
1724 @State private var settleScrollTask : Task < Void , Never > ?
18- /// Separate handle from `scrollTask`. Owns the fade-in / scroll-on-switch
19- /// sequence so a concurrent content-growth `scrollToBottomDebounced()`
20- /// (which also writes to `scrollTask`) can't cancel the session-ready flip.
25+ /// Owns the fade-in. A separate handle so the content-growth path
26+ /// (`scrollToBottomDebounced`, which owns `scrollTask`) can't cancel the
27+ /// session-ready flip.
2128 @State private var readyTask : Task < Void , Never > ?
2229 @State private var anchor = AutoScrollAnchor ( )
2330 @State private var isSessionReady = false
24- /// Visible height of the message `List` — drives the dynamic tail spacer.
25- @State private var viewportHeight : CGFloat = 0
26- /// `minY` of the latest user message in `chatContentCoordinateSpace`.
27- @State private var latestUserMinY : CGFloat = 0
28- /// `minY` of the tail spacer in `chatContentCoordinateSpace`.
29- @State private var tailSpacerMinY : CGFloat = 0
30- /// Latest user message id already seen — distinguishes a genuine new send
31- /// from a session switch / disk load.
32- @State private var lastTrackedUserID : UUID ?
33- /// Session the `lastTrackedUserID` baseline belongs to.
34- @State private var pinSessionID : String ?
31+ /// Latest scroll phase — gates auto-scroll so it never fires while the user
32+ /// is driving the scroll.
33+ @State private var scrollPhase : ScrollPhase = . idle
3534
3635 private static let log = Logger ( subsystem: " com.claudework " , category: " MessageListView " )
3736 private static let bottomAnchorID = " message-list-bottom-anchor "
@@ -51,8 +50,6 @@ struct MessageListView: View {
5150 // Streaming view is outside VStack — text deltas don't affect settled layout
5251 if !windowState. focusMode {
5352 StreamingMessageView {
54- // While streaming, the dynamic tail spacer keeps the pinned
55- // question in place — no scroll-to-bottom.
5653 rebuildSettledItems ( )
5754 }
5855 // Suppress layout animations when switching sessions so the pulse indicator
@@ -83,24 +80,18 @@ struct MessageListView: View {
8380 . chatMessageListRowStyle ( )
8481 }
8582
86- tailSpacer
83+ bottomAnchor
8784 }
8885
89- /// Dynamic tail spacer: pads the latest turn so the user message can always
90- /// be scrolled to the very top, and shrinks as the assistant response grows
91- /// so the pinned message stays put.
92- private var tailSpacer : some View {
86+ /// A 1pt sentinel row at the end of the transcript — the target every
87+ /// scroll-to-bottom aims at.
88+ private var bottomAnchor : some View {
9389 Color . clear
94- . frame ( height: tailSpacerHeight )
90+ . frame ( height: 1 )
9591 . id ( Self . bottomAnchorID)
9692 . listRowInsets ( EdgeInsets ( ) )
9793 . listRowSeparator ( . hidden)
9894 . listRowBackground ( Color . clear)
99- . onGeometryChange ( for: CGFloat . self) { proxy in
100- proxy. frame ( in: . named( chatContentCoordinateSpace) ) . minY
101- } action: { newValue in
102- updateTailSpacerMinY ( newValue)
103- }
10495 }
10596
10697 private func messageList( proxy: ScrollViewProxy ) -> some View {
@@ -110,30 +101,23 @@ struct MessageListView: View {
110101 . scrollContentBackground ( . hidden)
111102 . environment ( \. defaultMinListRowHeight, 0 )
112103 . opacity ( isSessionReady ? 1 : 0 )
113- . coordinateSpace ( . named( chatContentCoordinateSpace) )
114- . environment ( \. chatTrackedMessageID, trackedUserMessageID)
115- . environment ( \. chatTrackedMessageGeometry, updateLatestUserMinY)
116- . onGeometryChange ( for: CGFloat . self) { proxy in
117- proxy. size. height
118- } action: { newValue in
119- viewportHeight = newValue
120- }
121- let scrolling = base
122104 . onScrollGeometryChange ( for: ScrollSample . self) { geo in
123105 ScrollSample ( contentHeight: geo. contentSize. height, visibleMaxY: geo. visibleRect. maxY)
124106 } action: { _, sample in
125107 // Route the geometry change through AutoScrollAnchor so content
126- // growth (e.g. an Edit/Bash card expanding) doesn't un-stick the
127- // anchor — only deliberate user scrolling does. Suppressed while
128- // streaming: the dynamic spacer keeps the question pinned.
108+ // growth (streaming text, an Edit/Bash card expanding) keeps the
109+ // list glued to the bottom — only a deliberate user scroll
110+ // un-sticks it. Suppressed while the user drives the scroll so
111+ // auto-scroll never fights a drag.
129112 let decision = anchor. apply ( contentHeight: sample. contentHeight, visibleMaxY: sample. visibleMaxY)
130- if decision == . scrollToBottom && !chatBridge . isStreaming {
113+ if decision == . scrollToBottom, !isUserDrivenScroll {
131114 scrollToBottomDebounced ( proxy)
132115 }
133116 }
134- . onChange ( of : trackedUserMessageID ) { _, newID in
135- handleTrackedUserChange ( newID , proxy : proxy )
117+ . onScrollPhaseChange { _, newPhase in
118+ scrollPhase = newPhase
136119 }
120+ let scrolling = base
137121 . task ( id: windowState. currentSessionId) {
138122 await handleSessionTask ( proxy: proxy)
139123 }
@@ -171,7 +155,7 @@ struct MessageListView: View {
171155 if chatBridge. isStreaming {
172156 rebuildSettledItems ( )
173157 anchor. resetToBottom ( )
174- syncPinTracking ( )
158+ settleAtBottom ( proxy : proxy , reason : " sessionTask.streaming " )
175159 if !isSessionReady { isSessionReady = true }
176160 Self . log. info ( " [MessageList.task] streaming-path settled= \( settledItems. count) sid= \( sid, privacy: . public) " )
177161 return
@@ -181,12 +165,10 @@ struct MessageListView: View {
181165 settleScrollTask? . cancel ( )
182166 readyTask? . cancel ( )
183167 rebuildSettledItems ( )
184- syncPinTracking ( )
185168 Self . log. info ( " [MessageList.task] post-rebuild settled= \( settledItems. count) sid= \( sid, privacy: . public) isLoadingFromDisk= \( chatBridge. isLoadingFromDisk) " )
186- // Skip scroll/fade delay for empty sessions — appear instantly,
187- // unless we're still loading persisted messages from disk (in which
188- // case the onChange handler below will fade the list in once messages
189- // arrive, avoiding the empty → populated "blink").
169+ // Empty sessions appear instantly — unless we're still loading persisted
170+ // messages from disk, in which case `handleLoadingChange` fades the list
171+ // in once messages arrive, avoiding the empty → populated "blink".
190172 guard !settledItems. isEmpty else {
191173 if !chatBridge. isLoadingFromDisk {
192174 isSessionReady = true
@@ -196,10 +178,12 @@ struct MessageListView: View {
196178 }
197179 return
198180 }
199- try ? await Task . sleep ( for: . milliseconds( 16 ) ) // 1 frame: scroll after VStack layout is committed
200- scrollToBottom ( proxy)
181+ // Re-assert the bottom across several frames: a single scroll can fire
182+ // before `List` has realized the freshly-rebuilt rows, stranding the
183+ // view at the top — which the fade-in would then reveal.
184+ settleAtBottom ( proxy: proxy, reason: " sessionOpen " )
201185 anchor. resetToBottom ( )
202- try ? await Task . sleep ( for: . milliseconds( 32 ) ) // 2 frames: fade-in after scroll settles
186+ try ? await Task . sleep ( for: . milliseconds( 48 ) ) // let the first re-asserts land before fade-in
203187 withAnimation ( . easeIn( duration: 0.15 ) ) { isSessionReady = true }
204188 }
205189
@@ -210,35 +194,28 @@ struct MessageListView: View {
210194 // rebuild the settled list and fade in — same sequence as the .task above.
211195 guard !isLoading else { return }
212196 rebuildSettledItems ( )
213- syncPinTracking ( )
214197 Self . log. info ( " [MessageList.onLoadChange] post-rebuild settled= \( settledItems. count) sid= \( sid, privacy: . public) " )
198+ readyTask? . cancel ( )
199+ settleAtBottom ( proxy: proxy, reason: " loadFinished " )
200+ anchor. resetToBottom ( )
215201 // Fade-in lives on `readyTask` so the content-growth path
216202 // (`scrollToBottomDebounced`, which owns `scrollTask`) cannot cancel it.
217- readyTask? . cancel ( )
218203 readyTask = Task { @MainActor in
219- try ? await Task . sleep ( for: . milliseconds( 16 ) )
220- guard !Task. isCancelled else { return }
221- scrollToBottom ( proxy)
222- anchor. resetToBottom ( )
223- guard !isSessionReady else { return }
224- try ? await Task . sleep ( for: . milliseconds( 32 ) )
225- guard !Task. isCancelled else { return }
204+ try ? await Task . sleep ( for: . milliseconds( 48 ) )
205+ guard !Task. isCancelled, !isSessionReady else { return }
226206 withAnimation ( . easeIn( duration: 0.15 ) ) { isSessionReady = true }
227207 }
228208 }
229209
230210 private func handleStreamingChange( old: Bool , new: Bool , proxy: ScrollViewProxy ) {
231- // Only update when streaming ends — settled list doesn't change at start.
211+ // Only react when streaming ends — the settled list doesn't change at start.
232212 guard old && !new else { return }
233213 rebuildSettledItems ( )
234214 anchor. resetToBottom ( )
235- // Keep the question pinned to the top through the row handoff; fall back
236- // to the bottom only when there is no user message.
237- if let pinned = trackedUserMessageID {
238- pinScrollDuringHandoff ( to: pinned, anchor: . top, proxy: proxy)
239- } else {
240- pinScrollDuringHandoff ( to: Self . bottomAnchorID, anchor: . bottom, proxy: proxy)
241- }
215+ // The just-finished turn moves out of `StreamingMessageView` and into
216+ // the settled list. That row handoff makes `List` reload and can
217+ // momentarily snap the offset; re-assert the bottom across the handoff.
218+ settleAtBottom ( proxy: proxy, reason: " streamingEnded " )
242219 }
243220
244221 // MARK: - Helpers
@@ -248,8 +225,6 @@ struct MessageListView: View {
248225 ChatMessageListView ( messages: Array ( messages) )
249226 }
250227
251- // MARK: - Message Grouping
252-
253228 // MARK: - Settled Items
254229
255230 private func rebuildSettledItems( ) {
@@ -276,102 +251,53 @@ struct MessageListView: View {
276251 return chatSuppressPlanReadyFollowups ( in: settled)
277252 }
278253
254+ // MARK: - Scrolling
255+
256+ /// `true` while the user is driving the scroll — finger/trackpad down or a
257+ /// post-flick glide. Our own programmatic `.animating` scroll and the
258+ /// settled `.idle` state are not user-driven.
259+ private var isUserDrivenScroll : Bool {
260+ switch scrollPhase {
261+ case . interacting, . tracking, . decelerating: return true
262+ case . idle, . animating: return false
263+ @unknown default : return false
264+ }
265+ }
266+
279267 private func scrollToBottom( _ proxy: ScrollViewProxy ) {
280268 proxy. scrollTo ( Self . bottomAnchorID, anchor: . bottom)
281269 }
282270
271+ /// Debounced scroll-to-bottom for content growth. Re-armed on every geometry
272+ /// change, so a burst of streaming deltas collapses into a single scroll.
283273 private func scrollToBottomDebounced( _ proxy: ScrollViewProxy ) {
284274 scrollTask? . cancel ( )
285275 scrollTask = Task { @MainActor in
286276 try ? await Task . sleep ( for: . milliseconds( 50 ) )
287- guard !Task. isCancelled else { return }
277+ guard !Task. isCancelled, !isUserDrivenScroll else { return }
288278 scrollToBottom ( proxy)
289279 }
290280 }
291281
292- /// When a stream ends, the just-completed assistant turn moves out of
293- /// `StreamingMessageView` and into the settled list. That row handoff makes
294- /// `List` reload, which can momentarily snap the scroll offset to the top —
295- /// the user sees the list jump up, then jump back down. A single debounced
296- /// scroll can't fix it reliably: the streaming-insertion animation keeps
297- /// emitting scroll-geometry changes that re-arm and starve the debounce.
298- /// Re-assert the desired anchor on every frame for a short window so the
299- /// snap is corrected within one frame, before it becomes visible .
300- private func pinScrollDuringHandoff ( to id : some Hashable , anchor : UnitPoint , proxy : ScrollViewProxy ) {
282+ /// Re-assert the bottom on every frame for a short window.
283+ ///
284+ /// A single scroll can land before `List` has realized the freshly-rebuilt
285+ /// rows (session open / disk load), or while the streaming→settled row
286+ /// handoff is still reloading the list — in both cases the offset snaps to
287+ /// the top. Re-asserting corrects the snap within a frame, before it becomes
288+ /// visible. Bails the moment the user grabs the scroll so it never fights a
289+ /// drag — our own `.animating` scroll is not user-driven .
290+ private func settleAtBottom ( proxy : ScrollViewProxy , reason : String ) {
301291 settleScrollTask? . cancel ( )
292+ Self . log. info ( " [ScrollSettle] reason= \( reason, privacy: . public) sid= \( windowState. currentSessionId ?? " <nil> " , privacy: . public) settled= \( settledItems. count) " )
302293 settleScrollTask = Task { @MainActor in
303294 for _ in 0 ..< 12 {
304- guard !Task. isCancelled else { return }
305- proxy . scrollTo ( id , anchor : anchor )
295+ guard !Task. isCancelled, !isUserDrivenScroll else { return }
296+ scrollToBottom ( proxy )
306297 try ? await Task . sleep ( for: . milliseconds( 16 ) )
307298 }
308299 }
309300 }
310-
311- // MARK: - Pin to top
312-
313- /// The latest user message — the row pinned to the top on send and the
314- /// reference point for the dynamic tail spacer.
315- private var trackedUserMessageID : UUID ? {
316- chatBridge. messages. last ( where: { $0. role == . user } ) ? . id
317- }
318-
319- /// Height of the tail spacer: pads the latest turn so the user message can
320- /// always be scrolled to the very top. Shrinks as the response grows, so the
321- /// pinned message stays put without any further scrolling.
322- private var tailSpacerHeight : CGFloat {
323- guard viewportHeight > 0 else { return 1 }
324- let latestTurnHeight = max ( 0 , tailSpacerMinY - latestUserMinY)
325- return max ( 1 , viewportHeight - latestTurnHeight)
326- }
327-
328- /// Re-baseline the new-send detector to the current session so a session
329- /// switch or disk load is never mistaken for a freshly sent message.
330- private func syncPinTracking( ) {
331- pinSessionID = windowState. currentSessionId
332- lastTrackedUserID = trackedUserMessageID
333- }
334-
335- /// React to the latest user message changing: pin a genuine new send to the
336- /// top, but ignore the change when it is really a session switch.
337- private func handleTrackedUserChange( _ newID: UUID ? , proxy: ScrollViewProxy ) {
338- let sid = windowState. currentSessionId
339- guard pinSessionID == sid else {
340- // Session switch — re-baseline without pinning.
341- pinSessionID = sid
342- lastTrackedUserID = newID
343- return
344- }
345- guard let newID, newID != lastTrackedUserID else { return }
346- lastTrackedUserID = newID
347- guard !chatBridge. isLoadingFromDisk else { return }
348- // A genuine new user message in the active session — pin it to the top.
349- scrollTask? . cancel ( )
350- scrollTask = Task { @MainActor in
351- try ? await Task . sleep ( for: . milliseconds( 16 ) ) // 1 frame: spacer + row layout
352- guard !Task. isCancelled else { return }
353- withAnimation ( . easeOut( duration: 0.28 ) ) {
354- proxy. scrollTo ( newID, anchor: . top)
355- }
356- }
357- }
358-
359- /// `minY` of the latest user message — fed back from `ChatMessageListView`.
360- private func updateLatestUserMinY( _ value: CGFloat ) {
361- guard abs ( value - latestUserMinY) > 0.5 else { return }
362- var t = Transaction ( )
363- t. animation = nil
364- withTransaction ( t) { latestUserMinY = value }
365- }
366-
367- /// `minY` of the tail spacer — its distance from the user message is the
368- /// height of the latest turn.
369- private func updateTailSpacerMinY( _ value: CGFloat ) {
370- guard abs ( value - tailSpacerMinY) > 0.5 else { return }
371- var t = Transaction ( )
372- t. animation = nil
373- withTransaction ( t) { tailSpacerMinY = value }
374- }
375301}
376302
377303// MARK: - Streaming Message (isolated view — chatBridge.messages dependency confined to this view)
0 commit comments