From 6b02e11d94031c545ae43a644cefcbe7a31abed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Sat, 1 Aug 2026 13:26:45 +0200 Subject: [PATCH 1/2] MessagesView: per-session scroll persistence primitives Add SavedScroll state and messages_reset_for_session to MessagesView so that switching sessions saves/restores the logical scroll anchor and follow-tail flag, and a same-session resync freezes the visible offset instead of jumping to the bottom. --- crates/ui_gpui/src/messages/mod.rs | 223 ++++++++++++++++++++++++++++- 1 file changed, 220 insertions(+), 3 deletions(-) diff --git a/crates/ui_gpui/src/messages/mod.rs b/crates/ui_gpui/src/messages/mod.rs index 87e61bf2..6f759daf 100644 --- a/crates/ui_gpui/src/messages/mod.rs +++ b/crates/ui_gpui/src/messages/mod.rs @@ -8,13 +8,14 @@ use crate::Gpui; use code_assistant_core::session::instance::SessionActivityState; use gpui::{ - App, Bounds, Context, Entity, FocusHandle, Focusable, ListAlignment, ListState, MouseButton, - MouseMoveEvent, MouseUpEvent, Pixels, SharedString, Task, Window, div, list, prelude::*, px, - rems, + App, Bounds, Context, Entity, FocusHandle, Focusable, ListAlignment, ListOffset, ListState, + MouseButton, MouseMoveEvent, MouseUpEvent, Pixels, SharedString, Task, Window, div, list, + prelude::*, px, rems, }; use gpui_component::scroll::ScrollableElement; use gpui_component::{ActiveTheme, Icon}; use std::cell::Cell; +use std::collections::HashMap; use std::rc::Rc; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -33,6 +34,18 @@ const BRAILLE_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦ /// stay centered rather than stretching edge-to-edge for comfortable reading. const MAX_MESSAGE_WIDTH: f32 = 720.0; +/// Persisted scroll state for a single session. +/// +/// The `anchor` is the list's *logical* scroll position (an item index plus a +/// pixel offset within that item), which survives remeasuring far better than a +/// raw pixel offset. `follow_tail` records whether the user was pinned to the +/// bottom, so a session that was following the tail keeps doing so on return. +#[derive(Clone, Copy)] +struct SavedScroll { + anchor: ListOffset, + follow_tail: bool, +} + /// MessagesView - Component responsible for displaying the message history. /// /// Uses GPUI's virtualized `list()` element to only render messages that are @@ -86,6 +99,18 @@ pub struct MessagesView { edge_drag_active: Rc>, /// Running edge auto-scroll task. Dropping it cancels the loop. edge_scroll_task: Option>, + + // -- Per-session scroll persistence -- + /// The session id whose scroll position the `list_state` currently + /// reflects. Distinct from `current_session_id` (which the app sets + /// eagerly on switch): this is updated only when the list is actually + /// rebuilt, so a reset can compare the two to tell a genuine session + /// switch apart from a same-session resync. + displayed_session_id: Option, + /// Last known scroll anchor + follow-tail flag per session, captured + /// whenever we switch away. Restored when the session is shown again so + /// the user sees it exactly as they left it. + saved_scroll: HashMap, } impl MessagesView { @@ -152,6 +177,8 @@ impl MessagesView { edge_scroll_velocity: Rc::new(Cell::new(0.0)), edge_drag_active: Rc::new(Cell::new(false)), edge_scroll_task: None, + displayed_session_id: None, + saved_scroll: HashMap::new(), } } @@ -203,6 +230,115 @@ impl MessagesView { tracing::trace!("ListState reset with {} items", new_count); } + /// Session-aware variant of [`Self::messages_reset`]. + /// + /// The message queue is rebuilt from scratch on two very different + /// occasions, and they want opposite scroll behavior: + /// + /// * **Session switch** (`session_id` differs from the currently displayed + /// one): save the outgoing session's scroll position, then either restore + /// the incoming session's remembered position or — if it was never shown — + /// follow the tail. + /// * **Same-session resync** (`session_id` unchanged): a stream lag, a file + /// watcher refresh, or a structural edit rebuilt the identical transcript. + /// The visible viewport must not jump, so we snapshot the scroll anchor + /// before the reset and restore it afterwards (freeze the offset). + pub fn messages_reset_for_session( + &mut self, + session_id: Option, + new_count: usize, + cx: &mut Context, + ) { + let is_switch = session_id != self.displayed_session_id; + + if is_switch { + // Persist where we were in the session we are leaving. + self.save_current_scroll(); + + self.list_state.reset(new_count); + self.stop_animation(); + + // Restore the target session's remembered scroll, or follow the + // tail if we've never displayed it before. + let restored = session_id + .as_ref() + .and_then(|id| self.saved_scroll.get(id).copied()); + + self.displayed_session_id = session_id; + + match restored { + Some(saved) if !saved.follow_tail => { + self.follow_tail = false; + if new_count > 0 { + self.list_state.scroll_to(saved.anchor); + self.schedule_height_cache_refresh(cx); + } + } + // Either following the tail previously, or a fresh session. + _ => { + self.follow_tail = true; + if new_count > 0 { + self.scroll_to_bottom_instant(); + self.schedule_height_cache_refresh(cx); + } + } + } + tracing::trace!( + "ListState reset for session switch → {} items, follow_tail={}", + new_count, + self.follow_tail + ); + } else { + // Same-session resync: freeze the visible offset. Snapshot the + // anchor, rebuild, and restore it (unless we were following the + // tail, in which case stay pinned to the bottom). + let anchor = self.list_state.logical_scroll_top(); + let was_following = self.follow_tail; + + self.list_state.reset(new_count); + self.stop_animation(); + + if new_count == 0 { + self.follow_tail = true; + } else if was_following { + self.follow_tail = true; + self.scroll_to_bottom_instant(); + self.schedule_height_cache_refresh(cx); + } else { + self.follow_tail = false; + self.list_state.scroll_to(anchor); + self.schedule_height_cache_refresh(cx); + } + tracing::trace!( + "ListState reset for same-session resync → {} items, follow_tail={}", + new_count, + self.follow_tail + ); + } + } + + /// Snapshot the current scroll anchor + follow-tail flag for the session + /// the list currently reflects, so it can be restored on return. + fn save_current_scroll(&mut self) { + if let Some(id) = self.displayed_session_id.clone() { + self.saved_scroll.insert( + id, + SavedScroll { + anchor: self.list_state.logical_scroll_top(), + follow_tail: self.follow_tail, + }, + ); + } + } + + /// Forget any remembered scroll for a session (e.g. after deletion). + pub fn forget_session_scroll(&mut self, session_id: &str) { + self.saved_scroll.remove(session_id); + if self.displayed_session_id.as_deref() == Some(session_id) { + self.displayed_session_id = None; + } + } + // ----------------------------------------------------------------- // Scrolling helpers // ----------------------------------------------------------------- @@ -1165,4 +1301,85 @@ mod tests { }) .unwrap(); } + + #[gpui::test] + fn test_same_session_reset_preserves_scroll_offset(cx: &mut TestAppContext) { + let queue = Arc::new(Mutex::new(Vec::new())); + let activity = Arc::new(Mutex::new(None)); + + let window = cx.update(|cx| { + init_test_globals(cx); + cx.open_window(Default::default(), |_, cx| { + cx.new(|cx| MessagesView::new(queue, activity, cx)) + }) + .unwrap() + }); + + window + .update(cx, |view, _, cx| { + // Show a session with some messages, scrolled away from the bottom. + view.messages_reset_for_session(Some("s1".to_string()), 10, cx); + view.follow_tail = false; + view.list_state.scroll_to(ListOffset { + item_ix: 4, + offset_in_item: px(7.0), + }); + + // A same-session resync arrives (e.g. lagged stream, file watcher). + view.messages_reset_for_session(Some("s1".to_string()), 10, cx); + + // The scroll anchor must be preserved, not jumped to bottom. + let anchor = view.list_state.logical_scroll_top(); + assert_eq!(anchor.item_ix, 4); + assert_eq!(anchor.offset_in_item, px(7.0)); + // And follow_tail stays disabled. + assert!(!view.follow_tail); + }) + .unwrap(); + } + + #[gpui::test] + fn test_session_switch_saves_and_restores_scroll(cx: &mut TestAppContext) { + let queue = Arc::new(Mutex::new(Vec::new())); + let activity = Arc::new(Mutex::new(None)); + + let window = cx.update(|cx| { + init_test_globals(cx); + cx.open_window(Default::default(), |_, cx| { + cx.new(|cx| MessagesView::new(queue, activity, cx)) + }) + .unwrap() + }); + + window + .update(cx, |view, _, cx| { + // Session s1: scroll to a specific anchor and stop following. + view.messages_reset_for_session(Some("s1".to_string()), 10, cx); + view.follow_tail = false; + view.list_state.scroll_to(ListOffset { + item_ix: 3, + offset_in_item: px(12.0), + }); + + // Switch to s2 — s1's scroll must be saved; s2 (unseen) defaults + // to following the tail. + view.messages_reset_for_session(Some("s2".to_string()), 8, cx); + assert!(view.follow_tail, "unseen session should follow the tail"); + + // Scroll s2 somewhere too. + view.follow_tail = false; + view.list_state.scroll_to(ListOffset { + item_ix: 1, + offset_in_item: px(0.0), + }); + + // Back to s1 — the saved anchor and follow_tail=false are restored. + view.messages_reset_for_session(Some("s1".to_string()), 10, cx); + assert!(!view.follow_tail, "returning session restores follow state"); + let anchor = view.list_state.logical_scroll_top(); + assert_eq!(anchor.item_ix, 3); + assert_eq!(anchor.offset_in_item, px(12.0)); + }) + .unwrap(); + } } From 563c11f4487263f8badd3b3d5ac5b3f0fac3a695 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Sat, 1 Aug 2026 13:28:14 +0200 Subject: [PATCH 2/2] Wire session-aware scroll reset into the GPUI app Route notify_messages_reset and remove_empty_containers through MessagesView::messages_reset_for_session with the current session id, so that: - switching sessions restores each session's remembered scroll position and follow-tail state, - a same-session resync (stream lag, file-watcher refresh, structural edit) freezes the visible offset instead of snapping to the bottom. Session deletion forgets the remembered scroll for that session. --- crates/ui_gpui/src/lib.rs | 15 +++++++++++---- crates/ui_gpui/src/main_screen/mod.rs | 1 + 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/ui_gpui/src/lib.rs b/crates/ui_gpui/src/lib.rs index e613d67d..7b2cf25c 100644 --- a/crates/ui_gpui/src/lib.rs +++ b/crates/ui_gpui/src/lib.rs @@ -262,11 +262,15 @@ impl Gpui { } /// Notify the MessagesView that the message_queue was fully reset (cleared + reloaded). - /// This resets the ListState, discarding all cached heights. + /// This resets the ListState. Scroll handling is session-aware: switching + /// sessions saves/restores each session's scroll position, while a + /// same-session resync (stream lag, file-watcher refresh, structural edit) + /// freezes the visible offset instead of jumping to the bottom. fn notify_messages_reset(&self, cx: &mut gpui::AsyncApp) { let new_len = self.message_queue.lock().unwrap().len(); + let session_id = self.current_session_id.lock().unwrap().clone(); self.update_messages_view(cx, |view, cx| { - view.messages_reset(new_len, cx); + view.messages_reset_for_session(session_id, new_len, cx); cx.notify(); }); } @@ -296,9 +300,12 @@ impl Gpui { drop(queue); if new_len != old_len { - // Full reset since items may have been removed from arbitrary positions + // Full reset since items may have been removed from arbitrary positions. + // Route through the session-aware reset so the visible offset is + // frozen (same-session change) rather than snapping to the bottom. + let session_id = self.current_session_id.lock().unwrap().clone(); self.update_messages_view(cx, |view, cx| { - view.messages_reset(new_len, cx); + view.messages_reset_for_session(session_id, new_len, cx); cx.notify(); }); } diff --git a/crates/ui_gpui/src/main_screen/mod.rs b/crates/ui_gpui/src/main_screen/mod.rs index dbeecc22..85b9c3b8 100644 --- a/crates/ui_gpui/src/main_screen/mod.rs +++ b/crates/ui_gpui/src/main_screen/mod.rs @@ -658,6 +658,7 @@ impl MainScreen { // in the SessionDeleted response handler will be a no-op). self.messages_view.update(cx, |view, cx| { view.set_current_session_id(None); + view.forget_session_scroll(session_id.as_str()); view.messages_reset(0, cx); cx.notify(); });