gpui: Refactor follow_tail implementation to fix scroll snapping bugs (#53101)

Danilo Leal and Mikayla Maki created

Follow up to https://github.com/zed-industries/zed/pull/53017

This PR does some significant refactoring of the `follow_tail` feature
in the GPUI list. That's only used by the agent panel's thread view and
given to the height-changing nature of streaming agent responses, we
were seeing some scroll snapping bugs upon scrolling while the thread is
generating. In the process of fixing it, we introduced a
`remeasure_items` method as an alternative to `splice` so that we could
get the remeasurement fix without scroll position changes. We already
had a `remeasure` method that did that for all of the indexes, but we
needed something more scoped out for the agent panel case, so as to not
remeasure the entire list's content on every new streamed token.

Effectively, this ends up reverting what the PR linked above introduced,
but it improved the API in the process.
 
Release Notes:

- N/A

Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>

Change summary

crates/agent_ui/src/conversation_view.rs             |  63 -
crates/agent_ui/src/conversation_view/thread_view.rs |  28 
crates/gpui/src/elements/list.rs                     | 426 ++++++++++++-
3 files changed, 390 insertions(+), 127 deletions(-)

Detailed changes

crates/agent_ui/src/conversation_view.rs πŸ”—

@@ -831,6 +831,8 @@ impl ConversationView {
 
         let count = thread.read(cx).entries().len();
         let list_state = ListState::new(0, gpui::ListAlignment::Top, px(2048.0));
+        list_state.set_follow_mode(gpui::FollowMode::Tail);
+
         entry_view_state.update(cx, |view_state, cx| {
             for ix in 0..count {
                 view_state.sync_entry(ix, &thread, window, cx);
@@ -844,7 +846,7 @@ impl ConversationView {
         if let Some(scroll_position) = thread.read(cx).ui_scroll_position() {
             list_state.scroll_to(scroll_position);
         } else {
-            list_state.set_follow_tail(true);
+            list_state.scroll_to_end();
         }
 
         AgentDiff::set_active_thread(&self.workspace, thread.clone(), window, cx);
@@ -1243,15 +1245,15 @@ impl ConversationView {
                 if let Some(active) = self.thread_view(&thread_id) {
                     let entry_view_state = active.read(cx).entry_view_state.clone();
                     let list_state = active.read(cx).list_state.clone();
-                    notify_entry_changed(
-                        &entry_view_state,
-                        &list_state,
-                        index..index,
-                        index,
-                        thread,
-                        window,
-                        cx,
-                    );
+                    entry_view_state.update(cx, |view_state, cx| {
+                        view_state.sync_entry(index, thread, window, cx);
+                        list_state.splice_focusable(
+                            index..index,
+                            [view_state
+                                .entry(index)
+                                .and_then(|entry| entry.focus_handle(cx))],
+                        );
+                    });
                     active.update(cx, |active, cx| {
                         active.sync_editor_mode_for_empty_state(cx);
                     });
@@ -1261,15 +1263,10 @@ impl ConversationView {
                 if let Some(active) = self.thread_view(&thread_id) {
                     let entry_view_state = active.read(cx).entry_view_state.clone();
                     let list_state = active.read(cx).list_state.clone();
-                    notify_entry_changed(
-                        &entry_view_state,
-                        &list_state,
-                        *index..*index + 1,
-                        *index,
-                        thread,
-                        window,
-                        cx,
-                    );
+                    entry_view_state.update(cx, |view_state, cx| {
+                        view_state.sync_entry(*index, thread, window, cx);
+                    });
+                    list_state.remeasure_items(*index..*index + 1);
                     active.update(cx, |active, cx| {
                         active.auto_expand_streaming_thought(cx);
                     });
@@ -1313,7 +1310,6 @@ impl ConversationView {
                             active.clear_auto_expand_tracking();
                             if active.list_state.is_following_tail() {
                                 active.list_state.scroll_to_end();
-                                active.list_state.set_follow_tail(false);
                             }
                         }
                         active.sync_generating_indicator(cx);
@@ -1391,7 +1387,6 @@ impl ConversationView {
                             active.thread_retry_status.take();
                             if active.list_state.is_following_tail() {
                                 active.list_state.scroll_to_end();
-                                active.list_state.set_follow_tail(false);
                             }
                         }
                         active.sync_generating_indicator(cx);
@@ -2608,32 +2603,6 @@ impl ConversationView {
     }
 }
 
-/// Syncs an entry's view state with the latest thread data and splices
-/// the list item so the list knows to re-measure it on the next paint.
-///
-/// Used by both `NewEntry` (splice range `index..index` to insert) and
-/// `EntryUpdated` (splice range `index..index+1` to replace), which is
-/// why the caller provides the splice range.
-fn notify_entry_changed(
-    entry_view_state: &Entity<EntryViewState>,
-    list_state: &ListState,
-    splice_range: std::ops::Range<usize>,
-    index: usize,
-    thread: &Entity<AcpThread>,
-    window: &mut Window,
-    cx: &mut App,
-) {
-    entry_view_state.update(cx, |view_state, cx| {
-        view_state.sync_entry(index, thread, window, cx);
-        list_state.splice_focusable(
-            splice_range,
-            [view_state
-                .entry(index)
-                .and_then(|entry| entry.focus_handle(cx))],
-        );
-    });
-}
-
 fn loading_contents_spinner(size: IconSize) -> AnyElement {
     Icon::new(IconName::LoadCircle)
         .size(size)

crates/agent_ui/src/conversation_view/thread_view.rs πŸ”—

@@ -541,24 +541,15 @@ impl ThreadView {
         let thread_view = cx.entity().downgrade();
 
         this.list_state
-            .set_scroll_handler(move |event, _window, cx| {
+            .set_scroll_handler(move |_event, _window, cx| {
                 let list_state = list_state_for_scroll.clone();
                 let thread_view = thread_view.clone();
-                let is_following_tail = event.is_following_tail;
                 // N.B. We must defer because the scroll handler is called while the
                 // ListState's RefCell is mutably borrowed. Reading logical_scroll_top()
                 // directly would panic from a double borrow.
                 cx.defer(move |cx| {
                     let scroll_top = list_state.logical_scroll_top();
                     let _ = thread_view.update(cx, |this, cx| {
-                        if !is_following_tail {
-                            let is_generating =
-                                matches!(this.thread.read(cx).status(), ThreadStatus::Generating);
-
-                            if list_state.is_at_bottom() && is_generating {
-                                list_state.set_follow_tail(true);
-                            }
-                        }
                         if let Some(thread) = this.as_native_thread(cx) {
                             thread.update(cx, |thread, _cx| {
                                 thread.set_ui_scroll_position(Some(scroll_top));
@@ -1070,7 +1061,7 @@ impl ThreadView {
             })?;
 
             let _ = this.update(cx, |this, cx| {
-                this.list_state.set_follow_tail(true);
+                this.list_state.scroll_to_end();
                 cx.notify();
             });
 
@@ -4945,7 +4936,7 @@ impl ThreadView {
     }
 
     pub fn scroll_to_end(&mut self, cx: &mut Context<Self>) {
-        self.list_state.set_follow_tail(true);
+        self.list_state.scroll_to_end();
         cx.notify();
     }
 
@@ -4967,7 +4958,6 @@ impl ThreadView {
     }
 
     pub(crate) fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
-        self.list_state.set_follow_tail(false);
         self.list_state.scroll_to(ListOffset::default());
         cx.notify();
     }
@@ -4979,7 +4969,6 @@ impl ThreadView {
         cx: &mut Context<Self>,
     ) {
         let page_height = self.list_state.viewport_bounds().size.height;
-        self.list_state.set_follow_tail(false);
         self.list_state.scroll_by(-page_height * 0.9);
         cx.notify();
     }
@@ -4991,11 +4980,7 @@ impl ThreadView {
         cx: &mut Context<Self>,
     ) {
         let page_height = self.list_state.viewport_bounds().size.height;
-        self.list_state.set_follow_tail(false);
         self.list_state.scroll_by(page_height * 0.9);
-        if self.list_state.is_at_bottom() {
-            self.list_state.set_follow_tail(true);
-        }
         cx.notify();
     }
 
@@ -5005,7 +4990,6 @@ impl ThreadView {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        self.list_state.set_follow_tail(false);
         self.list_state.scroll_by(-window.line_height() * 3.);
         cx.notify();
     }
@@ -5016,11 +5000,7 @@ impl ThreadView {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        self.list_state.set_follow_tail(false);
         self.list_state.scroll_by(window.line_height() * 3.);
-        if self.list_state.is_at_bottom() {
-            self.list_state.set_follow_tail(true);
-        }
         cx.notify();
     }
 
@@ -5054,7 +5034,6 @@ impl ThreadView {
             .rev()
             .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_))))
         {
-            self.list_state.set_follow_tail(false);
             self.list_state.scroll_to(ListOffset {
                 item_ix: target_ix,
                 offset_in_item: px(0.),
@@ -5074,7 +5053,6 @@ impl ThreadView {
         if let Some(target_ix) = (current_ix + 1..entries.len())
             .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_))))
         {
-            self.list_state.set_follow_tail(false);
             self.list_state.scroll_to(ListOffset {
                 item_ix: target_ix,
                 offset_in_item: px(0.),

crates/gpui/src/elements/list.rs πŸ”—

@@ -72,7 +72,7 @@ struct StateInner {
     scrollbar_drag_start_height: Option<Pixels>,
     measuring_behavior: ListMeasuringBehavior,
     pending_scroll: Option<PendingScrollFraction>,
-    follow_tail: bool,
+    follow_state: FollowState,
 }
 
 /// Keeps track of a fractional scroll position within an item for restoration
@@ -84,6 +84,49 @@ struct PendingScrollFraction {
     fraction: f32,
 }
 
+/// Controls whether the list automatically follows new content at the end.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub enum FollowMode {
+    /// Normal scrolling β€” no automatic following.
+    #[default]
+    Normal,
+    /// The list should auto-scroll along with the tail, when scrolled to bottom.
+    Tail,
+}
+
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+enum FollowState {
+    #[default]
+    Normal,
+    Tail {
+        is_following: bool,
+    },
+}
+
+impl FollowState {
+    fn is_following(&self) -> bool {
+        matches!(self, FollowState::Tail { is_following: true })
+    }
+
+    fn has_stopped_following(&self) -> bool {
+        matches!(
+            self,
+            FollowState::Tail {
+                is_following: false
+            }
+        )
+    }
+
+    fn start_following(&mut self) {
+        if let FollowState::Tail {
+            is_following: false,
+        } = self
+        {
+            *self = FollowState::Tail { is_following: true };
+        }
+    }
+}
+
 /// Whether the list is scrolling from top to bottom or bottom to top.
 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
 pub enum ListAlignment {
@@ -169,6 +212,7 @@ pub struct ListPrepaintState {
 #[derive(Clone)]
 enum ListItem {
     Unmeasured {
+        size_hint: Option<Size<Pixels>>,
         focus_handle: Option<FocusHandle>,
     },
     Measured {
@@ -186,9 +230,16 @@ impl ListItem {
         }
     }
 
+    fn size_hint(&self) -> Option<Size<Pixels>> {
+        match self {
+            ListItem::Measured { size, .. } => Some(*size),
+            ListItem::Unmeasured { size_hint, .. } => *size_hint,
+        }
+    }
+
     fn focus_handle(&self) -> Option<FocusHandle> {
         match self {
-            ListItem::Unmeasured { focus_handle } | ListItem::Measured { focus_handle, .. } => {
+            ListItem::Unmeasured { focus_handle, .. } | ListItem::Measured { focus_handle, .. } => {
                 focus_handle.clone()
             }
         }
@@ -196,7 +247,7 @@ impl ListItem {
 
     fn contains_focused(&self, window: &Window, cx: &App) -> bool {
         match self {
-            ListItem::Unmeasured { focus_handle } | ListItem::Measured { focus_handle, .. } => {
+            ListItem::Unmeasured { focus_handle, .. } | ListItem::Measured { focus_handle, .. } => {
                 focus_handle
                     .as_ref()
                     .is_some_and(|handle| handle.contains_focused(window, cx))
@@ -240,7 +291,7 @@ impl ListState {
             scrollbar_drag_start_height: None,
             measuring_behavior: ListMeasuringBehavior::default(),
             pending_scroll: None,
-            follow_tail: false,
+            follow_state: FollowState::default(),
         })));
         this.splice(0..0, item_count);
         this
@@ -275,37 +326,63 @@ impl ListState {
     /// Use this when item heights may have changed (e.g., font size changes)
     /// but the number and identity of items remains the same.
     pub fn remeasure(&self) {
-        let state = &mut *self.0.borrow_mut();
+        let count = self.item_count();
+        self.remeasure_items(0..count);
+    }
 
-        let new_items = state.items.iter().map(|item| ListItem::Unmeasured {
-            focus_handle: item.focus_handle(),
-        });
+    /// Mark items in `range` as needing remeasurement while preserving
+    /// the current scroll position. Unlike [`Self::splice`], this does
+    /// not change the number of items or blow away `logical_scroll_top`.
+    ///
+    /// Use this when an item's content has changed and its rendered
+    /// height may be different (e.g., streaming text, tool results
+    /// loading), but the item itself still exists at the same index.
+    pub fn remeasure_items(&self, range: Range<usize>) {
+        let state = &mut *self.0.borrow_mut();
 
-        // If there's a `logical_scroll_top`, we need to keep track of it as a
-        // `PendingScrollFraction`, so we can later preserve that scroll
-        // position proportionally to the item, in case the item's height
-        // changes.
+        // If the scroll-top item falls within the remeasured range,
+        // store a fractional offset so the layout can restore the
+        // proportional scroll position after the item is re-rendered
+        // at its new height.
         if let Some(scroll_top) = state.logical_scroll_top {
-            let mut cursor = state.items.cursor::<Count>(());
-            cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
+            if range.contains(&scroll_top.item_ix) {
+                let mut cursor = state.items.cursor::<Count>(());
+                cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
 
-            if let Some(item) = cursor.item() {
-                if let Some(size) = item.size() {
-                    let fraction = if size.height.0 > 0.0 {
-                        (scroll_top.offset_in_item.0 / size.height.0).clamp(0.0, 1.0)
-                    } else {
-                        0.0
-                    };
-
-                    state.pending_scroll = Some(PendingScrollFraction {
-                        item_ix: scroll_top.item_ix,
-                        fraction,
-                    });
+                if let Some(item) = cursor.item() {
+                    if let Some(size) = item.size() {
+                        let fraction = if size.height.0 > 0.0 {
+                            (scroll_top.offset_in_item.0 / size.height.0).clamp(0.0, 1.0)
+                        } else {
+                            0.0
+                        };
+
+                        state.pending_scroll = Some(PendingScrollFraction {
+                            item_ix: scroll_top.item_ix,
+                            fraction,
+                        });
+                    }
                 }
             }
         }
 
-        state.items = SumTree::from_iter(new_items, ());
+        // Rebuild the tree, replacing items in the range with
+        // Unmeasured copies that keep their focus handles.
+        let new_items = {
+            let mut cursor = state.items.cursor::<Count>(());
+            let mut new_items = cursor.slice(&Count(range.start), Bias::Right);
+            let invalidated = cursor.slice(&Count(range.end), Bias::Right);
+            new_items.extend(
+                invalidated.iter().map(|item| ListItem::Unmeasured {
+                    size_hint: item.size_hint(),
+                    focus_handle: item.focus_handle(),
+                }),
+                (),
+            );
+            new_items.append(cursor.suffix(), ());
+            new_items
+        };
+        state.items = new_items;
         state.measuring_behavior.reset();
     }
 
@@ -339,7 +416,10 @@ impl ListState {
         new_items.extend(
             focus_handles.into_iter().map(|focus_handle| {
                 spliced_count += 1;
-                ListItem::Unmeasured { focus_handle }
+                ListItem::Unmeasured {
+                    size_hint: None,
+                    focus_handle,
+                }
             }),
             (),
         );
@@ -414,24 +494,37 @@ impl ListState {
         });
     }
 
-    /// Set whether the list should automatically follow the tail (auto-scroll to the end).
-    pub fn set_follow_tail(&self, follow: bool) {
-        self.0.borrow_mut().follow_tail = follow;
-        if follow {
-            self.scroll_to_end();
+    /// Set the follow mode for the list. In `Tail` mode, the list
+    /// will auto-scroll to the end and re-engage after the user
+    /// scrolls back to the bottom. In `Normal` mode, no automatic
+    /// following occurs.
+    pub fn set_follow_mode(&self, mode: FollowMode) {
+        let state = &mut *self.0.borrow_mut();
+
+        match mode {
+            FollowMode::Normal => {
+                state.follow_state = FollowState::Normal;
+            }
+            FollowMode::Tail => {
+                state.follow_state = FollowState::Tail { is_following: true };
+                if matches!(mode, FollowMode::Tail) {
+                    let item_count = state.items.summary().count;
+                    state.logical_scroll_top = Some(ListOffset {
+                        item_ix: item_count,
+                        offset_in_item: px(0.),
+                    });
+                }
+            }
         }
     }
 
-    /// Returns whether the list is currently in follow-tail mode (auto-scrolling to the end).
+    /// Returns whether the list is currently actively following the
+    /// tail (snapping to the end on each layout).
     pub fn is_following_tail(&self) -> bool {
-        self.0.borrow().follow_tail
-    }
-
-    /// Returns whether the list is scrolled to the bottom (within 1px).
-    pub fn is_at_bottom(&self) -> bool {
-        let current_offset = self.scroll_px_offset_for_scrollbar().y.abs();
-        let max_offset = self.max_offset_for_scrollbar().y;
-        current_offset >= max_offset - px(1.0)
+        matches!(
+            self.0.borrow().follow_state,
+            FollowState::Tail { is_following: true }
+        )
     }
 
     /// Scroll the list to the given offset
@@ -599,6 +692,7 @@ impl StateInner {
         if self.reset {
             return;
         }
+
         let padding = self.last_padding.unwrap_or_default();
         let scroll_max =
             (self.items.summary().height + padding.top + padding.bottom - height).max(px(0.));
@@ -620,8 +714,10 @@ impl StateInner {
             });
         }
 
-        if self.follow_tail && delta.y > px(0.) {
-            self.follow_tail = false;
+        if let FollowState::Tail { is_following } = &mut self.follow_state {
+            if delta.y > px(0.) {
+                *is_following = false;
+            }
         }
 
         if let Some(handler) = self.scroll_handler.as_mut() {
@@ -631,7 +727,10 @@ impl StateInner {
                     visible_range,
                     count: self.items.summary().count,
                     is_scrolled: self.logical_scroll_top.is_some(),
-                    is_following_tail: self.follow_tail,
+                    is_following_tail: matches!(
+                        self.follow_state,
+                        FollowState::Tail { is_following: true }
+                    ),
                 },
                 window,
                 cx,
@@ -722,7 +821,7 @@ impl StateInner {
         let mut max_item_width = px(0.);
         let mut scroll_top = self.logical_scroll_top();
 
-        if self.follow_tail {
+        if self.follow_state.is_following() {
             scroll_top = ListOffset {
                 item_ix: self.items.summary().count,
                 offset_in_item: px(0.),
@@ -875,6 +974,18 @@ impl StateInner {
         new_items.append(cursor.suffix(), ());
         self.items = new_items;
 
+        // If follow_tail mode is on but the user scrolled away
+        // (is_following is false), check whether the current scroll
+        // position has returned to the bottom.
+        if self.follow_state.has_stopped_following() {
+            let padding = self.last_padding.unwrap_or_default();
+            let total_height = self.items.summary().height + padding.top + padding.bottom;
+            let scroll_offset = self.scroll_top(&scroll_top);
+            if scroll_offset + available_height >= total_height - px(1.0) {
+                self.follow_state.start_following();
+            }
+        }
+
         // If none of the visible items are focused, check if an off-screen item is focused
         // and include it to be rendered after the visible items so keyboard interaction continues
         // to work for it.
@@ -1011,7 +1122,7 @@ impl StateInner {
             content_height - self.scrollbar_drag_start_height.unwrap_or(content_height);
         let new_scroll_top = (point.y - drag_offset).abs().max(px(0.)).min(scroll_max);
 
-        self.follow_tail = false;
+        self.follow_state = FollowState::Normal;
 
         if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max {
             self.logical_scroll_top = None;
@@ -1159,6 +1270,7 @@ impl Element for List {
         {
             let new_items = SumTree::from_iter(
                 state.items.iter().map(|item| ListItem::Unmeasured {
+                    size_hint: None,
                     focus_handle: item.focus_handle(),
                 }),
                 (),
@@ -1245,11 +1357,18 @@ impl sum_tree::Item for ListItem {
 
     fn summary(&self, _: ()) -> Self::Summary {
         match self {
-            ListItem::Unmeasured { focus_handle } => ListItemSummary {
+            ListItem::Unmeasured {
+                size_hint,
+                focus_handle,
+            } => ListItemSummary {
                 count: 1,
                 rendered_count: 0,
                 unrendered_count: 1,
-                height: px(0.),
+                height: if let Some(size) = size_hint {
+                    size.height
+                } else {
+                    px(0.)
+                },
                 has_focus_handles: focus_handle.is_some(),
             },
             ListItem::Measured {
@@ -1319,8 +1438,8 @@ mod test {
     use std::rc::Rc;
 
     use crate::{
-        self as gpui, AppContext, Context, Element, IntoElement, ListState, Render, Styled,
-        TestAppContext, Window, div, list, point, px, size,
+        self as gpui, AppContext, Context, Element, FollowMode, IntoElement, ListState, Render,
+        Styled, TestAppContext, Window, div, list, point, px, size,
     };
 
     #[gpui::test]
@@ -1545,7 +1664,7 @@ mod test {
             })
         });
 
-        state.set_follow_tail(true);
+        state.set_follow_mode(FollowMode::Tail);
 
         // First paint β€” items are 50px, total 500px, viewport 200px.
         // Follow-tail should anchor to the end.
@@ -1599,7 +1718,7 @@ mod test {
             }
         }
 
-        state.set_follow_tail(true);
+        state.set_follow_mode(FollowMode::Tail);
 
         // Paint with follow-tail β€” scroll anchored to the bottom.
         cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, cx| {
@@ -1641,7 +1760,7 @@ mod test {
 
         let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
 
-        state.set_follow_tail(true);
+        state.set_follow_mode(FollowMode::Tail);
 
         // Paint with follow-tail β€” scroll anchored to the bottom.
         cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
@@ -1709,7 +1828,7 @@ mod test {
 
         // Enable follow-tail β€” this should immediately snap the scroll anchor
         // to the end, like the user just sent a prompt.
-        state.set_follow_tail(true);
+        state.set_follow_mode(FollowMode::Tail);
 
         cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
             view.into_any_element()
@@ -1764,4 +1883,201 @@ mod test {
             -scroll_offset.y, max_offset.y,
         );
     }
+
+    /// When the user scrolls away from the bottom during follow_tail,
+    /// follow_tail suspends. If they scroll back to the bottom, the
+    /// next paint should re-engage follow_tail using fresh measurements.
+    #[gpui::test]
+    fn test_follow_tail_reengages_when_scrolled_back_to_bottom(cx: &mut TestAppContext) {
+        let cx = cx.add_empty_window();
+
+        // 10 items Γ— 50px = 500px total, 200px viewport.
+        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
+
+        struct TestView(ListState);
+        impl Render for TestView {
+            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+                list(self.0.clone(), |_, _, _| {
+                    div().h(px(50.)).w_full().into_any()
+                })
+                .w_full()
+                .h_full()
+            }
+        }
+
+        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
+
+        state.set_follow_mode(FollowMode::Tail);
+
+        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
+            view.clone().into_any_element()
+        });
+        assert!(state.is_following_tail());
+
+        // Scroll up β€” follow_tail should suspend (not fully disengage).
+        cx.simulate_event(ScrollWheelEvent {
+            position: point(px(50.), px(100.)),
+            delta: ScrollDelta::Pixels(point(px(0.), px(50.))),
+            ..Default::default()
+        });
+        assert!(!state.is_following_tail());
+
+        // Scroll back down to the bottom.
+        cx.simulate_event(ScrollWheelEvent {
+            position: point(px(50.), px(100.)),
+            delta: ScrollDelta::Pixels(point(px(0.), px(-10000.))),
+            ..Default::default()
+        });
+
+        // After a paint, follow_tail should re-engage because the
+        // layout confirmed we're at the true bottom.
+        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
+            view.clone().into_any_element()
+        });
+        assert!(
+            state.is_following_tail(),
+            "follow_tail should re-engage after scrolling back to the bottom"
+        );
+    }
+
+    /// When an item is spliced to unmeasured (0px) while follow_tail
+    /// is suspended, the re-engagement check should still work correctly
+    #[gpui::test]
+    fn test_follow_tail_reengagement_not_fooled_by_unmeasured_items(cx: &mut TestAppContext) {
+        let cx = cx.add_empty_window();
+
+        // 20 items Γ— 50px = 1000px total, 200px viewport, 1000px
+        // overdraw so all items get measured during the follow_tail
+        // paint (matching realistic production settings).
+        let state = ListState::new(20, crate::ListAlignment::Top, px(1000.));
+
+        struct TestView(ListState);
+        impl Render for TestView {
+            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+                list(self.0.clone(), |_, _, _| {
+                    div().h(px(50.)).w_full().into_any()
+                })
+                .w_full()
+                .h_full()
+            }
+        }
+
+        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
+
+        state.set_follow_mode(FollowMode::Tail);
+
+        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
+            view.clone().into_any_element()
+        });
+        assert!(state.is_following_tail());
+
+        // Scroll up a meaningful amount β€” suspends follow_tail.
+        // 20 items Γ— 50px = 1000px. viewport 200px. scroll_max = 800px.
+        // Scrolling up 200px puts us at 600px, clearly not at bottom.
+        cx.simulate_event(ScrollWheelEvent {
+            position: point(px(50.), px(100.)),
+            delta: ScrollDelta::Pixels(point(px(0.), px(200.))),
+            ..Default::default()
+        });
+        assert!(!state.is_following_tail());
+
+        // Invalidate the last item (simulates EntryUpdated calling
+        // remeasure_items). This makes items.summary().height
+        // temporarily wrong (0px for the invalidated item).
+        state.remeasure_items(19..20);
+
+        // Paint β€” layout re-measures the invalidated item with its true
+        // height. The re-engagement check uses these fresh measurements.
+        // Since we scrolled 200px up from the 800px max, we're at
+        // ~600px β€” NOT at the bottom, so follow_tail should NOT
+        // re-engage.
+        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
+            view.clone().into_any_element()
+        });
+        assert!(
+            !state.is_following_tail(),
+            "follow_tail should not falsely re-engage due to an unmeasured item \
+             reducing items.summary().height"
+        );
+    }
+
+    /// Calling `set_follow_mode(FollowState::Normal)` or dragging the scrollbar should
+    /// fully disengage follow_tail β€” clearing any suspended state so
+    /// follow_tail won’t auto-re-engage.
+    #[gpui::test]
+    fn test_follow_tail_suspended_state_cleared_by_explicit_actions(cx: &mut TestAppContext) {
+        let cx = cx.add_empty_window();
+
+        // 10 items Γ— 50px = 500px total, 200px viewport.
+        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
+
+        struct TestView(ListState);
+        impl Render for TestView {
+            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+                list(self.0.clone(), |_, _, _| {
+                    div().h(px(50.)).w_full().into_any()
+                })
+                .w_full()
+                .h_full()
+            }
+        }
+
+        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
+
+        state.set_follow_mode(FollowMode::Tail);
+        // --- Part 1: set_follow_mode(FollowState::Normal) clears suspended state ---
+
+        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
+            view.clone().into_any_element()
+        });
+
+        // Scroll up β€” suspends follow_tail.
+        cx.simulate_event(ScrollWheelEvent {
+            position: point(px(50.), px(100.)),
+            delta: ScrollDelta::Pixels(point(px(0.), px(50.))),
+            ..Default::default()
+        });
+        assert!(!state.is_following_tail());
+
+        // Scroll back to the bottom β€” should re-engage follow_tail.
+        cx.simulate_event(ScrollWheelEvent {
+            position: point(px(50.), px(100.)),
+            delta: ScrollDelta::Pixels(point(px(0.), px(-10000.))),
+            ..Default::default()
+        });
+
+        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
+            view.clone().into_any_element()
+        });
+        assert!(
+            state.is_following_tail(),
+            "follow_tail should re-engage after scrolling back to the bottom"
+        );
+
+        // --- Part 2: scrollbar drag clears suspended state ---
+
+        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
+            view.clone().into_any_element()
+        });
+
+        // Drag the scrollbar to the middle β€” should clear suspended state.
+        state.set_offset_from_scrollbar(point(px(0.), px(150.)));
+
+        // Scroll to the bottom.
+        cx.simulate_event(ScrollWheelEvent {
+            position: point(px(50.), px(100.)),
+            delta: ScrollDelta::Pixels(point(px(0.), px(-10000.))),
+            ..Default::default()
+        });
+
+        // Paint β€” should NOT re-engage because the scrollbar drag
+        // cleared the suspended state.
+        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
+            view.clone().into_any_element()
+        });
+        assert!(
+            !state.is_following_tail(),
+            "follow_tail should not re-engage after scrollbar drag cleared the suspended state"
+        );
+    }
 }