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/5] 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/5] 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(); }); From d71690706aac94117c68c871683afff23ef01e8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Mon, 3 Aug 2026 20:00:27 +0200 Subject: [PATCH 3/5] Fix scroll restore for sessions scrolled to the end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The instant jump-to-bottom used ListState::scroll_to_reveal_item, which computes a pixel target from item heights. Right after reset() the list items are unmeasured (zero height), so it landed at the TOP — visible when returning to a long session that was scrolled to the end. Use ListState::scroll_to_end instead: it anchors past the last item so the layout pass walks backwards from the end and reveals the true bottom regardless of measurement state. Also keep a following session pinned to the end across the height-cache remeasures. Adds a regression test asserting a follow-tail restore anchors past the last item. --- crates/ui_gpui/src/messages/mod.rs | 61 ++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/crates/ui_gpui/src/messages/mod.rs b/crates/ui_gpui/src/messages/mod.rs index 6f759daf..894b2101 100644 --- a/crates/ui_gpui/src/messages/mod.rs +++ b/crates/ui_gpui/src/messages/mod.rs @@ -356,10 +356,16 @@ impl MessagesView { } /// Jump to the bottom instantly (no animation). + /// + /// Uses [`ListState::scroll_to_end`] (anchoring past the last item) rather + /// than `scroll_to_reveal_item`: right after a `reset()` the list items are + /// still unmeasured, and `scroll_to_reveal_item` would compute a pixel + /// target from those zero heights and land at the top. `scroll_to_end` + /// instead lets the layout pass walk backwards from the end, which reveals + /// the true bottom regardless of measurement state. fn scroll_to_bottom_instant(&self) { - let count = self.list_state.item_count(); - if count > 0 { - self.list_state.scroll_to_reveal_item(count - 1); + if self.list_state.item_count() > 0 { + self.list_state.scroll_to_end(); } } @@ -375,6 +381,15 @@ impl MessagesView { return; } + if self.follow_tail { + // A following session stays pinned to the bottom across remeasures + // (e.g. markdown reflow settling after a restore). Anchor past the + // last item so the layout re-reveals the true bottom. + self.list_state.reset(count); + self.list_state.scroll_to_end(); + return; + } + let anchor = self.list_state.logical_scroll_top(); self.list_state.reset(count); self.list_state.scroll_to(anchor); @@ -1338,6 +1353,46 @@ mod tests { .unwrap(); } + #[gpui::test] + fn test_restoring_bottom_session_anchors_to_end(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| { + // A long session the user scrolled to the very end (follow_tail + // stays true near the bottom). + view.messages_reset_for_session(Some("long".to_string()), 100, cx); + assert!(view.follow_tail); + + // Switch away, then back. + view.messages_reset_for_session(Some("other".to_string()), 3, cx); + view.messages_reset_for_session(Some("long".to_string()), 100, cx); + + // A following session must anchor to the END sentinel so the + // layout can walk backwards and reveal the bottom — even though + // the freshly reset items are still unmeasured. Anchoring to a + // concrete item index (scroll_to_reveal_item) would land at the + // top here. + assert!(view.follow_tail); + let anchor = view.list_state.logical_scroll_top(); + assert_eq!( + anchor.item_ix, + view.list_state.item_count(), + "follow-tail restore must anchor past the last item (scroll_to_end)" + ); + }) + .unwrap(); + } + #[gpui::test] fn test_session_switch_saves_and_restores_scroll(cx: &mut TestAppContext) { let queue = Arc::new(Mutex::new(Vec::new())); From 6a1d11e1105d3ea945e39b65ca7378ce19a916ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Mon, 3 Aug 2026 22:08:53 +0200 Subject: [PATCH 4/5] Persist message-list scroll offset per session to disk Store the scroll anchor + follow-tail flag in the existing per-session UiSessionState (the same .ui_state.json that already holds tool collapse/diff overrides), so a session is shown as it was left across app restarts. Writes are throttled through the existing debounced PersistUiState flush: persist_current_scroll records the current position on real user scroll (wheel handler), on the edge-drag follow-tail transition, on the scroll-to-bottom button, and on session switch. A no-op guard in UiStateStore::set_scroll avoids re-dirtying on unchanged positions. Restore prefers the in-memory per-run position and falls back to the on-disk position (load_persisted_scroll). Adds UiStateStore scroll get/set + serialization tests. --- crates/ui_gpui/src/messages/mod.rs | 92 ++++++++++++++++++++--- crates/ui_gpui/src/messages/scroll.rs | 4 + crates/ui_gpui/src/shared/ui_state.rs | 101 ++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 9 deletions(-) diff --git a/crates/ui_gpui/src/messages/mod.rs b/crates/ui_gpui/src/messages/mod.rs index 894b2101..61e6b281 100644 --- a/crates/ui_gpui/src/messages/mod.rs +++ b/crates/ui_gpui/src/messages/mod.rs @@ -253,16 +253,21 @@ impl MessagesView { if is_switch { // Persist where we were in the session we are leaving. - self.save_current_scroll(); + self.save_current_scroll(cx); 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()); + // tail if we've never displayed it before. In-memory state (from + // an earlier switch this run) takes precedence over the on-disk + // position from a previous run. + let restored = session_id.as_ref().and_then(|id| { + self.saved_scroll + .get(id) + .copied() + .or_else(|| self.load_persisted_scroll(id)) + }); self.displayed_session_id = session_id; @@ -318,19 +323,84 @@ impl MessagesView { } /// 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) { + /// the list currently reflects, so it can be restored on return (both + /// in-memory this run and on disk across restarts). + fn save_current_scroll(&mut self, cx: &mut Context) { if let Some(id) = self.displayed_session_id.clone() { + let anchor = self.list_state.logical_scroll_top(); self.saved_scroll.insert( - id, + id.clone(), SavedScroll { - anchor: self.list_state.logical_scroll_top(), + anchor, follow_tail: self.follow_tail, }, ); + // Mark the on-disk state dirty and schedule the debounced flush so + // the position survives a restart even if the user never wheel- + // scrolled (e.g. only dragged the scrollbar) before switching. + if self.write_persisted_scroll(&id, anchor, self.follow_tail) + && let Some(sender) = cx.try_global::() + { + let _ = sender + .0 + .try_send(code_assistant_core::ui::UiEvent::PersistUiState); + } } } + /// Persist the current scroll position to the per-session UI-state store + /// and schedule a debounced flush to disk. Called on real user scrolling + /// (not on programmatic restores), so the write is throttled to the last + /// position after the user settles. + fn persist_current_scroll(&self, cx: &mut Context) { + let Some(id) = self.displayed_session_id.clone() else { + return; + }; + let anchor = self.list_state.logical_scroll_top(); + if self.write_persisted_scroll(&id, anchor, self.follow_tail) + && let Some(sender) = cx.try_global::() + { + let _ = sender + .0 + .try_send(code_assistant_core::ui::UiEvent::PersistUiState); + } + } + + /// Write a scroll position into the global UI-state store. Returns `true` + /// if the value changed (and the session was therefore marked dirty). + fn write_persisted_scroll( + &self, + session_id: &str, + anchor: gpui::ListOffset, + follow_tail: bool, + ) -> bool { + let pos = crate::shared::ui_state::ScrollPosition { + item_ix: anchor.item_ix, + offset_in_item: anchor.offset_in_item.into(), + follow_tail, + }; + if let Some(store) = crate::shared::ui_state::UiStateStore::try_global() + && let Ok(mut store) = store.lock() + { + return store.set_scroll(session_id, pos); + } + false + } + + /// Load a previously persisted scroll position for a session from the + /// global UI-state store (from a prior app run). + fn load_persisted_scroll(&self, session_id: &str) -> Option { + let store = crate::shared::ui_state::UiStateStore::try_global()?; + let pos = store.lock().ok()?.get_scroll(session_id)?; + Some(SavedScroll { + anchor: gpui::ListOffset { + item_ix: pos.item_ix, + offset_in_item: px(pos.offset_in_item), + }, + follow_tail: pos.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); @@ -671,6 +741,9 @@ impl MessagesView { // longer following the tail. if this.follow_tail && new_y > -max + 1.0 { this.follow_tail = false; + // Persist the new (non-following) position on this drag + // transition; subsequent frames are no-ops. + this.persist_current_scroll(cx); } cx.notify(); }); @@ -934,6 +1007,7 @@ impl Render for MessagesView { ) .on_click(cx.listener(|this, _event, _window, cx| { this.activate_follow_tail(); + this.persist_current_scroll(cx); cx.notify(); })), ), diff --git a/crates/ui_gpui/src/messages/scroll.rs b/crates/ui_gpui/src/messages/scroll.rs index cacf2b79..50e00216 100644 --- a/crates/ui_gpui/src/messages/scroll.rs +++ b/crates/ui_gpui/src/messages/scroll.rs @@ -97,6 +97,10 @@ pub fn install_scroll_handler( } } } + + // Remember the new position for this session, throttled to disk + // via the debounced UI-state flush. + this.persist_current_scroll(cx); }); }); }); diff --git a/crates/ui_gpui/src/shared/ui_state.rs b/crates/ui_gpui/src/shared/ui_state.rs index ea42e457..bea3d817 100644 --- a/crates/ui_gpui/src/shared/ui_state.rs +++ b/crates/ui_gpui/src/shared/ui_state.rs @@ -28,6 +28,22 @@ pub fn debounce_duration() -> std::time::Duration { // UiSessionState — the data model // --------------------------------------------------------------------------- +/// A persisted scroll position for a session's message list. +/// +/// 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 view was pinned to the +/// bottom. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq)] +pub struct ScrollPosition { + /// Index of the item at the top of the viewport. + pub item_ix: usize, + /// Pixel offset within that item. + pub offset_in_item: f32, + /// Whether the view was following the tail (pinned to the bottom). + pub follow_tail: bool, +} + /// Per-session UI state that is persisted to a separate file. /// /// New fields can be added freely with `#[serde(default)]` for backward @@ -51,6 +67,11 @@ pub struct UiSessionState { /// from the default (diff mode = true). #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub tool_diff_mode_overrides: HashMap, + + /// Last known scroll position of the message list, so the session is shown + /// exactly as it was left across app restarts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scroll: Option, } // --------------------------------------------------------------------------- @@ -153,6 +174,26 @@ impl UiStateStore { self.dirty.insert(session_id.to_owned()); } + /// Return the persisted scroll position for a session, loading from disk if + /// the session hasn't been loaded yet. + pub fn get_scroll(&mut self, session_id: &str) -> Option { + self.get(session_id).scroll + } + + /// Set the persisted scroll position for a session. Marks the session dirty + /// so the next debounced flush writes it to disk. Returns `true` if the + /// value actually changed (a no-op update from a settling scroll animation + /// neither dirties the session nor returns `true`). + pub fn set_scroll(&mut self, session_id: &str, scroll: ScrollPosition) -> bool { + let state = self.states.entry(session_id.to_owned()).or_default(); + if state.scroll == Some(scroll) { + return false; + } + state.scroll = Some(scroll); + self.dirty.insert(session_id.to_owned()); + true + } + /// Remove the in-memory state and on-disk file for a deleted session. pub fn remove_session(&mut self, session_id: &str) { self.states.remove(session_id); @@ -291,6 +332,65 @@ mod tests { assert_eq!(store.get_tool_diff_mode("s1", "tool-other"), None); } + #[test] + fn test_set_scroll_roundtrip_and_dirty() { + let (mut store, _dir) = test_store(); + assert_eq!(store.get_scroll("s1"), None); + + let pos = ScrollPosition { + item_ix: 7, + offset_in_item: 12.5, + follow_tail: false, + }; + store.set_scroll("s1", pos); + assert!(store.dirty.contains("s1")); + assert_eq!(store.get_scroll("s1"), Some(pos)); + } + + #[test] + fn test_set_scroll_no_op_when_unchanged() { + let (mut store, _dir) = test_store(); + let pos = ScrollPosition { + item_ix: 3, + offset_in_item: 0.0, + follow_tail: true, + }; + store.set_scroll("s1", pos); + // Clear dirty (simulate a flush), then set the identical value again. + store.dirty.clear(); + let changed = store.set_scroll("s1", pos); + assert!(!changed, "identical scroll must report no change"); + assert!( + !store.dirty.contains("s1"), + "identical scroll must not re-dirty the session" + ); + } + + #[test] + fn test_scroll_survives_serialization() { + let (mut store, _dir) = test_store(); + store.set_scroll( + "s1", + ScrollPosition { + item_ix: 42, + offset_in_item: 3.25, + follow_tail: false, + }, + ); + let files = store.take_dirty(); + assert_eq!(files.len(), 1); + let (_path, json) = &files[0]; + let parsed: UiSessionState = serde_json::from_str(json).unwrap(); + assert_eq!( + parsed.scroll, + Some(ScrollPosition { + item_ix: 42, + offset_in_item: 3.25, + follow_tail: false, + }) + ); + } + #[test] fn test_take_dirty_clears_dirty_set() { let (mut store, _dir) = test_store(); @@ -324,6 +424,7 @@ mod tests { plan_collapsed: true, tool_collapse_overrides: HashMap::from([("t1".to_owned(), false)]), tool_diff_mode_overrides: HashMap::new(), + scroll: None, }; let path = dir.path().join("s1.ui_state.json"); let mut f = std::fs::File::create(&path).unwrap(); From 81956e7e4f099e7b7ef7fabf39f2f37496f2f1fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Tue, 4 Aug 2026 09:14:37 +0200 Subject: [PATCH 5/5] Fold messages_reset into messages_reset_for_session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the standalone MessagesView::messages_reset — its reset-to-bottom logic overlapped with the session-aware path. The delete/disconnect call site and the two unit tests now use messages_reset_for_session(None, ...), which clears to the empty state via the same-session branch (the displayed session is cleared first, so no stale scroll is restored). --- crates/ui_gpui/src/main_screen/mod.rs | 5 ++++- crates/ui_gpui/src/messages/mod.rs | 23 +++++------------------ 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/crates/ui_gpui/src/main_screen/mod.rs b/crates/ui_gpui/src/main_screen/mod.rs index 85b9c3b8..ba428853 100644 --- a/crates/ui_gpui/src/main_screen/mod.rs +++ b/crates/ui_gpui/src/main_screen/mod.rs @@ -659,7 +659,10 @@ impl MainScreen { 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); + // Disconnect the view: clear to the empty state. Passing `None` + // routes through the session-aware reset (displayed session was + // just cleared above), so no stale scroll is restored. + view.messages_reset_for_session(None, 0, cx); cx.notify(); }); diff --git a/crates/ui_gpui/src/messages/mod.rs b/crates/ui_gpui/src/messages/mod.rs index 61e6b281..44aec4f4 100644 --- a/crates/ui_gpui/src/messages/mod.rs +++ b/crates/ui_gpui/src/messages/mod.rs @@ -216,21 +216,8 @@ impl MessagesView { } } - /// Notify that all messages have been cleared and replaced. - /// Resets the ListState with the new count. - pub fn messages_reset(&mut self, new_count: usize, cx: &mut Context) { - self.list_state.reset(new_count); - self.follow_tail = true; - // For a full reset, jump instantly — no need to animate. - self.stop_animation(); - if new_count > 0 { - self.scroll_to_bottom_instant(); - self.schedule_height_cache_refresh(cx); - } - tracing::trace!("ListState reset with {} items", new_count); - } - - /// Session-aware variant of [`Self::messages_reset`]. + /// Reset the message list after its contents were rebuilt from scratch, + /// making the scroll behavior session-aware. /// /// The message queue is rebuilt from scratch on two very different /// occasions, and they want opposite scroll behavior: @@ -1181,10 +1168,10 @@ mod tests { }) .unwrap(); - // Reset to 2 items + // Reset to 2 items (same-session clear/reload path) window .update(cx, |view, _, cx| { - view.messages_reset(2, cx); + view.messages_reset_for_session(None, 2, cx); assert_eq!(view.list_state.item_count(), 2); // follow_tail should be re-enabled on reset assert!(view.follow_tail); @@ -1208,7 +1195,7 @@ mod tests { window .update(cx, |view, _, cx| { view.messages_spliced(0, 10, cx); - view.messages_reset(0, cx); + view.messages_reset_for_session(None, 0, cx); assert_eq!(view.list_state.item_count(), 0); assert!(view.follow_tail); })