@@ -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)
@@ -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"
+ );
+ }
}