Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions crates/ui_gpui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
}
Expand Down Expand Up @@ -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();
});
}
Expand Down
1 change: 1 addition & 0 deletions crates/ui_gpui/src/main_screen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
223 changes: 220 additions & 3 deletions crates/ui_gpui/src/messages/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -86,6 +99,18 @@ pub struct MessagesView {
edge_drag_active: Rc<Cell<bool>>,
/// Running edge auto-scroll task. Dropping it cancels the loop.
edge_scroll_task: Option<Task<()>>,

// -- 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<String>,
/// 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<String, SavedScroll>,
}

impl MessagesView {
Expand Down Expand Up @@ -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(),
}
}

Expand Down Expand Up @@ -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<String>,
new_count: usize,
cx: &mut Context<Self>,
) {
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
// -----------------------------------------------------------------
Expand Down Expand Up @@ -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();
}
}
Loading