mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
fix(tui): prevent scroll jump while streaming (fixes #527)
This commit is contained in:
@@ -5178,7 +5178,7 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
let total_height = self.history_render.last_total_height();
|
||||
let max_scroll = total_height.saturating_sub(viewport_rows);
|
||||
let clamped_offset = self.layout.scroll_offset.min(max_scroll);
|
||||
let clamped_offset = self.layout.scroll_offset.get().min(max_scroll);
|
||||
let scroll_pos = max_scroll.saturating_sub(clamped_offset);
|
||||
let history_len = self.history_cells.len();
|
||||
let history_total = {
|
||||
@@ -6568,7 +6568,7 @@ impl ChatWidget<'_> {
|
||||
crate::height_manager::HeightManagerConfig::default(),
|
||||
)),
|
||||
layout: LayoutState {
|
||||
scroll_offset: 0,
|
||||
scroll_offset: Cell::new(0),
|
||||
last_max_scroll: std::cell::Cell::new(0),
|
||||
last_history_viewport_height: std::cell::Cell::new(0),
|
||||
vertical_scrollbar_state: std::cell::RefCell::new(ScrollbarState::default()),
|
||||
@@ -6941,7 +6941,7 @@ impl ChatWidget<'_> {
|
||||
crate::height_manager::HeightManagerConfig::default(),
|
||||
)),
|
||||
layout: LayoutState {
|
||||
scroll_offset: 0,
|
||||
scroll_offset: Cell::new(0),
|
||||
last_max_scroll: std::cell::Cell::new(0),
|
||||
last_history_viewport_height: std::cell::Cell::new(0),
|
||||
vertical_scrollbar_state: std::cell::RefCell::new(ScrollbarState::default()),
|
||||
@@ -8113,12 +8113,12 @@ impl ChatWidget<'_> {
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
InputResult::ScrollUp => {
|
||||
let before = self.layout.scroll_offset;
|
||||
let before = self.layout.scroll_offset.get();
|
||||
// Only allow Up to navigate command history when the top view
|
||||
// cannot be scrolled at all (no scrollback available).
|
||||
if self.layout.last_max_scroll.get() == 0 {
|
||||
if self.bottom_pane.try_history_up() {
|
||||
self.perf_track_scroll_delta(before, self.layout.scroll_offset);
|
||||
self.perf_track_scroll_delta(before, self.layout.scroll_offset.get());
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -8127,13 +8127,14 @@ impl ChatWidget<'_> {
|
||||
let new_offset = self
|
||||
.layout
|
||||
.scroll_offset
|
||||
.get()
|
||||
.saturating_add(3)
|
||||
.min(self.layout.last_max_scroll.get());
|
||||
self.layout.scroll_offset = new_offset;
|
||||
self.layout.scroll_offset.set(new_offset);
|
||||
self.flash_scrollbar();
|
||||
self.sync_history_virtualization();
|
||||
// Enable compact mode so history can use the spacer line
|
||||
if self.layout.scroll_offset > 0 {
|
||||
if self.layout.scroll_offset.get() > 0 {
|
||||
self.bottom_pane.set_compact_compose(true);
|
||||
self.height_manager
|
||||
.borrow_mut()
|
||||
@@ -8146,21 +8147,21 @@ impl ChatWidget<'_> {
|
||||
.borrow_mut()
|
||||
.record_event(HeightEvent::UserScroll);
|
||||
self.maybe_show_history_nav_hint_on_first_scroll();
|
||||
self.perf_track_scroll_delta(before, self.layout.scroll_offset);
|
||||
self.perf_track_scroll_delta(before, self.layout.scroll_offset.get());
|
||||
}
|
||||
InputResult::ScrollDown => {
|
||||
let before = self.layout.scroll_offset;
|
||||
let before = self.layout.scroll_offset.get();
|
||||
// Only allow Down to navigate command history when the top view
|
||||
// cannot be scrolled at all (no scrollback available).
|
||||
if self.layout.last_max_scroll.get() == 0 && self.bottom_pane.history_is_browsing()
|
||||
{
|
||||
if self.bottom_pane.try_history_down() {
|
||||
self.perf_track_scroll_delta(before, self.layout.scroll_offset);
|
||||
self.perf_track_scroll_delta(before, self.layout.scroll_offset.get());
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Scroll down in chat history (decrease offset, towards bottom)
|
||||
if self.layout.scroll_offset == 0 {
|
||||
if self.layout.scroll_offset.get() == 0 {
|
||||
// Already at bottom: ensure spacer above input is enabled.
|
||||
self.bottom_pane.set_compact_compose(false);
|
||||
self.sync_history_virtualization();
|
||||
@@ -8172,30 +8173,32 @@ impl ChatWidget<'_> {
|
||||
self.height_manager
|
||||
.borrow_mut()
|
||||
.record_event(HeightEvent::ComposerModeChange);
|
||||
self.perf_track_scroll_delta(before, self.layout.scroll_offset);
|
||||
} else if self.layout.scroll_offset >= 3 {
|
||||
self.perf_track_scroll_delta(before, self.layout.scroll_offset.get());
|
||||
} else if self.layout.scroll_offset.get() >= 3 {
|
||||
// Move towards bottom but do NOT toggle spacer yet; wait until
|
||||
// the user confirms by pressing Down again at bottom.
|
||||
self.layout.scroll_offset = self.layout.scroll_offset.saturating_sub(3);
|
||||
self.layout
|
||||
.scroll_offset
|
||||
.set(self.layout.scroll_offset.get().saturating_sub(3));
|
||||
self.sync_history_virtualization();
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
self.height_manager
|
||||
.borrow_mut()
|
||||
.record_event(HeightEvent::UserScroll);
|
||||
self.maybe_show_history_nav_hint_on_first_scroll();
|
||||
self.perf_track_scroll_delta(before, self.layout.scroll_offset);
|
||||
} else if self.layout.scroll_offset > 0 {
|
||||
self.perf_track_scroll_delta(before, self.layout.scroll_offset.get());
|
||||
} else if self.layout.scroll_offset.get() > 0 {
|
||||
// Land exactly at bottom without toggling spacer yet; require
|
||||
// a subsequent Down to re-enable the spacer so the input
|
||||
// doesn't move when scrolling into the line above it.
|
||||
self.layout.scroll_offset = 0;
|
||||
self.layout.scroll_offset.set(0);
|
||||
self.sync_history_virtualization();
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
self.height_manager
|
||||
.borrow_mut()
|
||||
.record_event(HeightEvent::UserScroll);
|
||||
self.maybe_show_history_nav_hint_on_first_scroll();
|
||||
self.perf_track_scroll_delta(before, self.layout.scroll_offset);
|
||||
self.perf_track_scroll_delta(before, self.layout.scroll_offset.get());
|
||||
}
|
||||
self.flash_scrollbar();
|
||||
}
|
||||
@@ -11028,7 +11031,7 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
fn submit_user_message(&mut self, user_message: UserMessage) {
|
||||
if self.layout.scroll_offset > 0 {
|
||||
if self.layout.scroll_offset.get() > 0 {
|
||||
layout_scroll::to_bottom(self);
|
||||
}
|
||||
// Surface a local diagnostic note and anchor it to the NEXT turn,
|
||||
@@ -17347,7 +17350,7 @@ impl ChatWidget<'_> {
|
||||
self.agents_terminal.clamp_selected_index();
|
||||
|
||||
if saw_new_agent && self.agents_terminal.active {
|
||||
self.layout.scroll_offset = 0;
|
||||
self.layout.scroll_offset.set(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17360,7 +17363,7 @@ impl ChatWidget<'_> {
|
||||
self.agents_terminal.focus_sidebar();
|
||||
self.agents_terminal.clear_stop_prompt();
|
||||
self.bottom_pane.set_input_focus(false);
|
||||
self.agents_terminal.saved_scroll_offset = self.layout.scroll_offset;
|
||||
self.agents_terminal.saved_scroll_offset = self.layout.scroll_offset.get();
|
||||
if self.agents_terminal.order.is_empty() {
|
||||
for agent in &self.active_agents {
|
||||
if !self
|
||||
@@ -17431,14 +17434,19 @@ impl ChatWidget<'_> {
|
||||
self.agents_terminal.active = false;
|
||||
self.agents_terminal.clear_stop_prompt();
|
||||
self.agents_terminal.focus_sidebar();
|
||||
self.layout.scroll_offset = self.agents_terminal.saved_scroll_offset;
|
||||
self.layout.scroll_offset
|
||||
.set(self.agents_terminal.saved_scroll_offset);
|
||||
self.bottom_pane.set_input_focus(true);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn record_current_agent_scroll(&mut self) {
|
||||
if let Some(entry) = self.agents_terminal.current_sidebar_entry() {
|
||||
let capped = self.layout.scroll_offset.min(self.layout.last_max_scroll.get());
|
||||
let capped = self
|
||||
.layout
|
||||
.scroll_offset
|
||||
.get()
|
||||
.min(self.layout.last_max_scroll.get());
|
||||
self
|
||||
.agents_terminal
|
||||
.scroll_offsets
|
||||
@@ -17455,9 +17463,9 @@ impl ChatWidget<'_> {
|
||||
.agents_terminal
|
||||
.scroll_offsets
|
||||
.insert(key, u16::MAX);
|
||||
self.layout.scroll_offset = u16::MAX;
|
||||
self.layout.scroll_offset.set(u16::MAX);
|
||||
} else {
|
||||
self.layout.scroll_offset = 0;
|
||||
self.layout.scroll_offset.set(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17470,7 +17478,7 @@ impl ChatWidget<'_> {
|
||||
.last_render_scroll
|
||||
.get()
|
||||
.min(self.layout.last_max_scroll.get());
|
||||
self.layout.scroll_offset = applied;
|
||||
self.layout.scroll_offset.set(applied);
|
||||
if let Some(entry) = self.agents_terminal.current_sidebar_entry() {
|
||||
self
|
||||
.agents_terminal
|
||||
@@ -23215,7 +23223,7 @@ Have we met every part of this goal and is there no further work to do?"#
|
||||
self.agents_terminal.reset();
|
||||
if self.agents_terminal.active {
|
||||
// Reset scroll offset when a new batch starts to avoid stale positions
|
||||
self.layout.scroll_offset = 0;
|
||||
self.layout.scroll_offset.set(0);
|
||||
}
|
||||
|
||||
// Initialize sparkline with some data so it shows immediately
|
||||
@@ -30248,7 +30256,7 @@ use code_core::protocol::OrderMeta;
|
||||
chat.resume_expected_next_request = None;
|
||||
chat.resume_provider_baseline = None;
|
||||
chat.synthetic_system_req = None;
|
||||
chat.layout.scroll_offset = 0;
|
||||
chat.layout.scroll_offset.set(0);
|
||||
chat.layout.last_max_scroll.set(0);
|
||||
chat.layout.last_history_viewport_height.set(0);
|
||||
#[cfg(any(test, feature = "test-helpers"))]
|
||||
@@ -32292,7 +32300,7 @@ use code_core::protocol::OrderMeta;
|
||||
insert_plain_cell(chat, &["new-1", "new-2"]);
|
||||
|
||||
let viewport_height = 6;
|
||||
chat.layout.scroll_offset = 2;
|
||||
chat.layout.scroll_offset.set(2);
|
||||
|
||||
let mut terminal = Terminal::new(TestBackend::new(40, viewport_height)).expect("terminal");
|
||||
terminal
|
||||
@@ -32388,7 +32396,7 @@ use code_core::protocol::OrderMeta;
|
||||
insert_plain_cell(chat, &["new-1", "new-2"]);
|
||||
|
||||
let viewport_height = 6;
|
||||
chat.layout.scroll_offset = 2;
|
||||
chat.layout.scroll_offset.set(2);
|
||||
|
||||
{
|
||||
let mut terminal =
|
||||
@@ -32429,7 +32437,7 @@ use code_core::protocol::OrderMeta;
|
||||
|
||||
let max_scroll = chat.layout.last_max_scroll.get();
|
||||
assert!(max_scroll > 0, "expected overflow to produce a positive max scroll");
|
||||
chat.layout.scroll_offset = max_scroll;
|
||||
chat.layout.scroll_offset.set(max_scroll);
|
||||
|
||||
let mut terminal = Terminal::new(TestBackend::new(40, 6)).expect("terminal");
|
||||
terminal
|
||||
@@ -32437,7 +32445,7 @@ use code_core::protocol::OrderMeta;
|
||||
.expect("draw history at top boundary");
|
||||
|
||||
let max_scroll = chat.layout.last_max_scroll.get();
|
||||
let scroll_from_top = max_scroll.saturating_sub(chat.layout.scroll_offset);
|
||||
let scroll_from_top = max_scroll.saturating_sub(chat.layout.scroll_offset.get());
|
||||
let effective = chat.history_render.adjust_scroll_to_content(scroll_from_top);
|
||||
let prefix = chat.history_render.prefix_sums.borrow();
|
||||
let mut start_idx = match prefix.binary_search(&effective) {
|
||||
@@ -38461,7 +38469,7 @@ impl WidgetRef for &ChatWidget<'_> {
|
||||
}
|
||||
|
||||
let composer_rows = self.layout.last_bottom_reserved_rows.get();
|
||||
let ensure_footer_space = self.layout.scroll_offset == 0
|
||||
let ensure_footer_space = self.layout.scroll_offset.get() == 0
|
||||
&& composer_rows > 0
|
||||
&& base_total_height >= viewport_rows
|
||||
&& request_count > 0;
|
||||
@@ -38502,8 +38510,11 @@ impl WidgetRef for &ChatWidget<'_> {
|
||||
}
|
||||
let overscan_extra = total_height.saturating_sub(base_total_height);
|
||||
// Calculate scroll position and vertical alignment
|
||||
// Stabilize viewport when input area height changes while scrolled up.
|
||||
// Preserve a stable viewport anchor when history grows while the user is scrolled up.
|
||||
let prev_viewport_h = self.layout.last_history_viewport_height.get();
|
||||
let prev_max_scroll = self.layout.last_max_scroll.get();
|
||||
let prev_scroll_offset = self.layout.scroll_offset.get().min(prev_max_scroll);
|
||||
let prev_scroll_from_top = prev_max_scroll.saturating_sub(prev_scroll_offset);
|
||||
if prev_viewport_h == 0 {
|
||||
// Initialize on first render
|
||||
self.layout
|
||||
@@ -38522,9 +38533,18 @@ impl WidgetRef for &ChatWidget<'_> {
|
||||
// scroll_offset is measured from the bottom (0 = bottom/newest)
|
||||
// Convert to distance from the top for rendering math.
|
||||
let max_scroll = total_height.saturating_sub(content_area.height);
|
||||
// Update cache and clamp for display only
|
||||
if self.layout.scroll_offset.get() > 0 && max_scroll != prev_max_scroll {
|
||||
// If the user has scrolled up and the history height changes (e.g. new output
|
||||
// arrives while streaming), keep the same content anchored at the top of the
|
||||
// viewport by adjusting our bottom-anchored scroll offset.
|
||||
self.layout
|
||||
.scroll_offset
|
||||
.set(max_scroll.saturating_sub(prev_scroll_from_top));
|
||||
}
|
||||
|
||||
// Update cache and clamp for display only.
|
||||
self.layout.last_max_scroll.set(max_scroll);
|
||||
let clamped_scroll_offset = self.layout.scroll_offset.min(max_scroll);
|
||||
let clamped_scroll_offset = self.layout.scroll_offset.get().min(max_scroll);
|
||||
let mut scroll_from_top = max_scroll.saturating_sub(clamped_scroll_offset);
|
||||
|
||||
if overscan_extra > 0 && clamped_scroll_offset == 0 {
|
||||
@@ -38552,21 +38572,6 @@ impl WidgetRef for &ChatWidget<'_> {
|
||||
"scrollback pre-adjust scroll position",
|
||||
);
|
||||
|
||||
// Viewport stabilization: when user is scrolled up (offset > 0) and the
|
||||
// history viewport height changes due to the input area growing/shrinking,
|
||||
// adjust the scroll_from_top to keep the top line steady on screen.
|
||||
if clamped_scroll_offset > 0 {
|
||||
let prev_h = prev_viewport_h as i32;
|
||||
let curr_h = content_area.height as i32;
|
||||
let delta_h = prev_h - curr_h; // positive if viewport shrank
|
||||
if delta_h != 0 {
|
||||
// Adjust in the opposite direction to keep the same top anchor
|
||||
let sft = scroll_from_top as i32 - delta_h;
|
||||
let sft = sft.clamp(0, max_scroll as i32) as u16;
|
||||
scroll_from_top = sft;
|
||||
}
|
||||
}
|
||||
|
||||
// If our scroll origin landed on a spacer row between cells, nudge it up so
|
||||
// the viewport starts with real content instead of an empty separator.
|
||||
let scroll_pos = if clamped_scroll_offset > 0 {
|
||||
@@ -40281,7 +40286,7 @@ struct StreamState {
|
||||
#[derive(Default)]
|
||||
struct LayoutState {
|
||||
// Scroll offset from bottom (0 = bottom)
|
||||
scroll_offset: u16,
|
||||
scroll_offset: Cell<u16>,
|
||||
// Cached max scroll from last render
|
||||
last_max_scroll: std::cell::Cell<u16>,
|
||||
// Track last viewport height of the history content area
|
||||
|
||||
@@ -5,47 +5,50 @@ use crate::height_manager::HeightEvent;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
pub(super) fn autoscroll_if_near_bottom(chat: &mut ChatWidget<'_>) {
|
||||
if chat.layout.scroll_offset <= 3 {
|
||||
let before = chat.layout.scroll_offset;
|
||||
chat.layout.scroll_offset = 0;
|
||||
if chat.layout.scroll_offset.get() <= 3 {
|
||||
let before = chat.layout.scroll_offset.get();
|
||||
chat.layout.scroll_offset.set(0);
|
||||
chat.bottom_pane.set_compact_compose(false);
|
||||
chat.height_manager
|
||||
.borrow_mut()
|
||||
.record_event(HeightEvent::ComposerModeChange);
|
||||
chat.perf_track_scroll_delta(before, chat.layout.scroll_offset);
|
||||
chat.perf_track_scroll_delta(before, chat.layout.scroll_offset.get());
|
||||
chat.sync_history_virtualization();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn page_up(chat: &mut ChatWidget<'_>) {
|
||||
let before = chat.layout.scroll_offset;
|
||||
let before = chat.layout.scroll_offset.get();
|
||||
let step = chat.layout.last_history_viewport_height.get().max(1);
|
||||
let new_offset = chat
|
||||
.layout.scroll_offset
|
||||
.layout
|
||||
.scroll_offset
|
||||
.get()
|
||||
.saturating_add(step)
|
||||
.min(chat.layout.last_max_scroll.get());
|
||||
chat.layout.scroll_offset = new_offset;
|
||||
chat.layout.scroll_offset.set(new_offset);
|
||||
chat.bottom_pane.set_compact_compose(true);
|
||||
flash_scrollbar(chat);
|
||||
chat.sync_history_virtualization();
|
||||
chat.app_event_tx.send(crate::app_event::AppEvent::RequestRedraw);
|
||||
chat.height_manager.borrow_mut().record_event(crate::height_manager::HeightEvent::UserScroll);
|
||||
chat.maybe_show_history_nav_hint_on_first_scroll();
|
||||
chat.perf_track_scroll_delta(before, chat.layout.scroll_offset);
|
||||
chat.perf_track_scroll_delta(before, chat.layout.scroll_offset.get());
|
||||
}
|
||||
|
||||
pub(super) fn line_up(chat: &mut ChatWidget<'_>) {
|
||||
let before = chat.layout.scroll_offset;
|
||||
let before = chat.layout.scroll_offset.get();
|
||||
let max_scroll = chat.layout.last_max_scroll.get();
|
||||
let new_offset = chat
|
||||
.layout
|
||||
.scroll_offset
|
||||
.get()
|
||||
.saturating_add(1)
|
||||
.min(max_scroll);
|
||||
if new_offset == chat.layout.scroll_offset {
|
||||
if new_offset == chat.layout.scroll_offset.get() {
|
||||
return;
|
||||
}
|
||||
chat.layout.scroll_offset = new_offset;
|
||||
chat.layout.scroll_offset.set(new_offset);
|
||||
chat.bottom_pane.set_compact_compose(true);
|
||||
flash_scrollbar(chat);
|
||||
chat.sync_history_virtualization();
|
||||
@@ -54,18 +57,18 @@ pub(super) fn line_up(chat: &mut ChatWidget<'_>) {
|
||||
.borrow_mut()
|
||||
.record_event(HeightEvent::UserScroll);
|
||||
chat.maybe_show_history_nav_hint_on_first_scroll();
|
||||
chat.perf_track_scroll_delta(before, chat.layout.scroll_offset);
|
||||
chat.perf_track_scroll_delta(before, chat.layout.scroll_offset.get());
|
||||
}
|
||||
|
||||
pub(super) fn line_down(chat: &mut ChatWidget<'_>) {
|
||||
let before = chat.layout.scroll_offset;
|
||||
if chat.layout.scroll_offset == 0 {
|
||||
chat.perf_track_scroll_delta(before, chat.layout.scroll_offset);
|
||||
let before = chat.layout.scroll_offset.get();
|
||||
if chat.layout.scroll_offset.get() == 0 {
|
||||
chat.perf_track_scroll_delta(before, chat.layout.scroll_offset.get());
|
||||
return;
|
||||
}
|
||||
let new_offset = chat.layout.scroll_offset.saturating_sub(1);
|
||||
chat.layout.scroll_offset = new_offset;
|
||||
if chat.layout.scroll_offset == 0 {
|
||||
let new_offset = chat.layout.scroll_offset.get().saturating_sub(1);
|
||||
chat.layout.scroll_offset.set(new_offset);
|
||||
if chat.layout.scroll_offset.get() == 0 {
|
||||
chat.bottom_pane.set_compact_compose(false);
|
||||
}
|
||||
flash_scrollbar(chat);
|
||||
@@ -75,16 +78,18 @@ pub(super) fn line_down(chat: &mut ChatWidget<'_>) {
|
||||
.borrow_mut()
|
||||
.record_event(HeightEvent::UserScroll);
|
||||
chat.maybe_show_history_nav_hint_on_first_scroll();
|
||||
chat.perf_track_scroll_delta(before, chat.layout.scroll_offset);
|
||||
chat.perf_track_scroll_delta(before, chat.layout.scroll_offset.get());
|
||||
}
|
||||
|
||||
pub(super) fn page_down(chat: &mut ChatWidget<'_>) {
|
||||
let before = chat.layout.scroll_offset;
|
||||
let before = chat.layout.scroll_offset.get();
|
||||
let step = chat.layout.last_history_viewport_height.get().max(1);
|
||||
if chat.layout.scroll_offset > step {
|
||||
chat.layout.scroll_offset = chat.layout.scroll_offset.saturating_sub(step);
|
||||
if chat.layout.scroll_offset.get() > step {
|
||||
chat.layout
|
||||
.scroll_offset
|
||||
.set(chat.layout.scroll_offset.get().saturating_sub(step));
|
||||
} else {
|
||||
chat.layout.scroll_offset = 0;
|
||||
chat.layout.scroll_offset.set(0);
|
||||
chat.bottom_pane.set_compact_compose(false);
|
||||
}
|
||||
flash_scrollbar(chat);
|
||||
@@ -92,42 +97,46 @@ pub(super) fn page_down(chat: &mut ChatWidget<'_>) {
|
||||
chat.app_event_tx.send(crate::app_event::AppEvent::RequestRedraw);
|
||||
chat.height_manager.borrow_mut().record_event(crate::height_manager::HeightEvent::UserScroll);
|
||||
chat.maybe_show_history_nav_hint_on_first_scroll();
|
||||
chat.perf_track_scroll_delta(before, chat.layout.scroll_offset);
|
||||
chat.perf_track_scroll_delta(before, chat.layout.scroll_offset.get());
|
||||
}
|
||||
|
||||
pub(super) fn mouse_scroll(chat: &mut ChatWidget<'_>, up: bool) {
|
||||
let before = chat.layout.scroll_offset;
|
||||
let before = chat.layout.scroll_offset.get();
|
||||
if up {
|
||||
let new_offset = chat
|
||||
.layout.scroll_offset
|
||||
.layout
|
||||
.scroll_offset
|
||||
.get()
|
||||
.saturating_add(3)
|
||||
.min(chat.layout.last_max_scroll.get());
|
||||
chat.layout.scroll_offset = new_offset;
|
||||
chat.layout.scroll_offset.set(new_offset);
|
||||
flash_scrollbar(chat);
|
||||
if chat.layout.scroll_offset > 0 {
|
||||
if chat.layout.scroll_offset.get() > 0 {
|
||||
chat.bottom_pane.set_compact_compose(true);
|
||||
}
|
||||
chat.sync_history_virtualization();
|
||||
chat.app_event_tx.send(crate::app_event::AppEvent::RequestRedraw);
|
||||
chat.maybe_show_history_nav_hint_on_first_scroll();
|
||||
} else {
|
||||
if chat.layout.scroll_offset >= 3 {
|
||||
chat.layout.scroll_offset = chat.layout.scroll_offset.saturating_sub(3);
|
||||
if chat.layout.scroll_offset.get() >= 3 {
|
||||
chat.layout
|
||||
.scroll_offset
|
||||
.set(chat.layout.scroll_offset.get().saturating_sub(3));
|
||||
chat.sync_history_virtualization();
|
||||
chat.app_event_tx.send(crate::app_event::AppEvent::RequestRedraw);
|
||||
chat.maybe_show_history_nav_hint_on_first_scroll();
|
||||
} else if chat.layout.scroll_offset > 0 {
|
||||
chat.layout.scroll_offset = 0;
|
||||
} else if chat.layout.scroll_offset.get() > 0 {
|
||||
chat.layout.scroll_offset.set(0);
|
||||
chat.sync_history_virtualization();
|
||||
chat.app_event_tx.send(crate::app_event::AppEvent::RequestRedraw);
|
||||
chat.maybe_show_history_nav_hint_on_first_scroll();
|
||||
}
|
||||
flash_scrollbar(chat);
|
||||
if chat.layout.scroll_offset == 0 {
|
||||
if chat.layout.scroll_offset.get() == 0 {
|
||||
chat.bottom_pane.set_compact_compose(false);
|
||||
}
|
||||
}
|
||||
chat.perf_track_scroll_delta(before, chat.layout.scroll_offset);
|
||||
chat.perf_track_scroll_delta(before, chat.layout.scroll_offset.get());
|
||||
}
|
||||
|
||||
pub(super) fn flash_scrollbar(chat: &ChatWidget<'_>) {
|
||||
@@ -143,8 +152,8 @@ pub(super) fn flash_scrollbar(chat: &ChatWidget<'_>) {
|
||||
|
||||
/// Jump to the very top of the history (oldest content).
|
||||
pub(super) fn to_top(chat: &mut ChatWidget<'_>) {
|
||||
let before = chat.layout.scroll_offset;
|
||||
chat.layout.scroll_offset = chat.layout.last_max_scroll.get();
|
||||
let before = chat.layout.scroll_offset.get();
|
||||
chat.layout.scroll_offset.set(chat.layout.last_max_scroll.get());
|
||||
chat.bottom_pane.set_compact_compose(true);
|
||||
flash_scrollbar(chat);
|
||||
chat.sync_history_virtualization();
|
||||
@@ -154,13 +163,13 @@ pub(super) fn to_top(chat: &mut ChatWidget<'_>) {
|
||||
.borrow_mut()
|
||||
.record_event(HeightEvent::UserScroll);
|
||||
chat.maybe_show_history_nav_hint_on_first_scroll();
|
||||
chat.perf_track_scroll_delta(before, chat.layout.scroll_offset);
|
||||
chat.perf_track_scroll_delta(before, chat.layout.scroll_offset.get());
|
||||
}
|
||||
|
||||
/// Jump to the very bottom of the history (latest content).
|
||||
pub(super) fn to_bottom(chat: &mut ChatWidget<'_>) {
|
||||
let before = chat.layout.scroll_offset;
|
||||
chat.layout.scroll_offset = 0;
|
||||
let before = chat.layout.scroll_offset.get();
|
||||
chat.layout.scroll_offset.set(0);
|
||||
chat.bottom_pane.set_compact_compose(false);
|
||||
flash_scrollbar(chat);
|
||||
chat.sync_history_virtualization();
|
||||
@@ -171,7 +180,7 @@ pub(super) fn to_bottom(chat: &mut ChatWidget<'_>) {
|
||||
.record_event(HeightEvent::UserScroll);
|
||||
// No hint necessary when landing at bottom, but keep behavior consistent
|
||||
chat.maybe_show_history_nav_hint_on_first_scroll();
|
||||
chat.perf_track_scroll_delta(before, chat.layout.scroll_offset);
|
||||
chat.perf_track_scroll_delta(before, chat.layout.scroll_offset.get());
|
||||
}
|
||||
|
||||
pub(super) fn layout_areas(chat: &ChatWidget<'_>, area: Rect) -> Vec<Rect> {
|
||||
|
||||
@@ -438,7 +438,7 @@ impl ChatWidgetHarness {
|
||||
|
||||
pub(crate) fn layout_metrics(&self) -> LayoutMetrics {
|
||||
LayoutMetrics {
|
||||
scroll_offset: self.chat.layout.scroll_offset,
|
||||
scroll_offset: self.chat.layout.scroll_offset.get(),
|
||||
last_viewport_height: self.chat.layout.last_history_viewport_height.get(),
|
||||
last_max_scroll: self.chat.layout.last_max_scroll.get(),
|
||||
}
|
||||
@@ -730,11 +730,11 @@ impl ChatWidgetHarness {
|
||||
}
|
||||
|
||||
pub(crate) fn force_scroll_offset(&mut self, offset: u16) {
|
||||
self.chat.layout.scroll_offset = offset;
|
||||
self.chat.layout.scroll_offset.set(offset);
|
||||
}
|
||||
|
||||
pub(crate) fn scroll_offset(&self) -> u16 {
|
||||
self.chat.layout.scroll_offset
|
||||
self.chat.layout.scroll_offset.get()
|
||||
}
|
||||
|
||||
pub(crate) fn poll_until<F>(&mut self, mut predicate: F, timeout: Duration) -> Vec<AppEvent>
|
||||
|
||||
@@ -1161,6 +1161,51 @@ fn scroll_spacing_remains_when_scrolled_up() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_position_stable_when_history_grows_scrolled_up() {
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
|
||||
for idx in 0..6 {
|
||||
harness.push_user_prompt(format!("User prompt #{idx}: scroll stability"));
|
||||
harness.push_assistant_markdown(format!(
|
||||
"Assistant response #{idx} with enough text to wrap and keep the history tall across renders.\n\nSecond paragraph for idx {idx}."
|
||||
));
|
||||
}
|
||||
|
||||
let _ = render_chat_widget_to_vt100(&mut harness, 80, 24);
|
||||
let metrics = harness_layout_metrics(&harness);
|
||||
assert!(
|
||||
metrics.last_max_scroll > 0,
|
||||
"scenario must overflow the history viewport to exercise scrolling"
|
||||
);
|
||||
|
||||
let offset = metrics.last_max_scroll.min(5).max(1);
|
||||
harness_force_scroll_offset(&mut harness, offset);
|
||||
let _ = render_chat_widget_to_vt100(&mut harness, 80, 24);
|
||||
let before = harness_layout_metrics(&harness);
|
||||
let before_from_top =
|
||||
before
|
||||
.last_max_scroll
|
||||
.saturating_sub(before.scroll_offset.min(before.last_max_scroll));
|
||||
|
||||
// Simulate additional output arriving while the user has scrolled up.
|
||||
harness.push_assistant_markdown(
|
||||
"New assistant output appended after the user scrolled up.\n\nThis should not move the viewport.",
|
||||
);
|
||||
let _ = render_chat_widget_to_vt100(&mut harness, 80, 24);
|
||||
let after = harness_layout_metrics(&harness);
|
||||
let after_from_top =
|
||||
after
|
||||
.last_max_scroll
|
||||
.saturating_sub(after.scroll_offset.min(after.last_max_scroll));
|
||||
|
||||
assert_eq!(
|
||||
before_from_top,
|
||||
after_from_top,
|
||||
"scroll jumped while history grew; user viewport should stay anchored"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_final_history_line_visible_at_bottom() {
|
||||
init_tracing_once();
|
||||
|
||||
Reference in New Issue
Block a user