test(tui): pin auto-scroll churn contract for P2.4 regression coverage

Audit pass found the auto-scroll paths are already gated correctly:

- `mark_history_updated` only bumps history_version + needs_redraw — does
  NOT scroll.
- All tool-cell handlers (`handle_tool_call_started`,
  `handle_tool_call_complete`, `push_active_tool_cell`,
  `register_tool_cell`) call `mark_history_updated` only — none of them
  call `scroll_to_bottom`.
- `add_message` and `flush_active_cell` gate their auto-scroll on
  `user_scrolled_during_stream`.
- The per-stream lock clears at TurnComplete (ui.rs ~557) and when the
  user scrolls back to the live tail (widgets/mod.rs ~126).
- Explicit user actions (vim G, End, session resume, message submit) call
  `scroll_to_bottom` directly — that's correct.

5 new regression tests in ui/tests.rs lock the contract so a future
contributor adding `app.scroll_to_bottom()` to a tool-cell handler hits a
red CI immediately:

- add_message_does_not_scroll_when_user_scrolled_away
- add_message_pins_to_tail_when_user_was_following
- tool_call_started_does_not_scroll_when_user_scrolled_away
- tool_call_complete_does_not_scroll_when_user_scrolled_away
- mark_history_updated_does_not_call_scroll_to_bottom

948 → 948 (no changes; tests were already passing — they just weren't
written yet).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-04-26 00:00:43 -05:00
parent 75de26c7a1
commit 06355e3aea
+112
View File
@@ -1399,3 +1399,115 @@ fn exploring_label_for_list_files_uses_progressive() {
// `running_status_label_with_elapsed` lives in `crate::tui::history` next to
// the other tool-header helpers — its tests live there too.
// ---- P2.4: auto-scroll churn regressions ----
//
// The contract: once the user scrolls away from the live tail mid-turn
// (`user_scrolled_during_stream = true`), no path should yank them back to
// the bottom until either (a) they explicitly scroll to tail, (b) the turn
// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers
// only call `mark_history_updated`, which does NOT scroll. `add_message`
// gates on the flag.
#[test]
fn add_message_does_not_scroll_when_user_scrolled_away() {
use crate::tui::scrolling::TranscriptScroll;
let mut app = create_test_app();
// Pre-condition: user was following the tail, then scrolled up.
app.transcript_scroll = TranscriptScroll::at_line(7);
app.user_scrolled_during_stream = true;
app.add_message(HistoryCell::User {
content: "fresh user message".to_string(),
});
assert!(
!app.transcript_scroll.is_at_tail(),
"add_message must respect user_scrolled_during_stream",
);
}
#[test]
fn add_message_pins_to_tail_when_user_was_following() {
use crate::tui::scrolling::TranscriptScroll;
let mut app = create_test_app();
app.transcript_scroll = TranscriptScroll::to_bottom();
app.user_scrolled_during_stream = false;
app.add_message(HistoryCell::User {
content: "fresh user message".to_string(),
});
assert!(
app.transcript_scroll.is_at_tail(),
"auto-pin should still work when the user hasn't opted out",
);
}
#[test]
fn tool_call_started_does_not_scroll_when_user_scrolled_away() {
// Tool-cell handlers must not sneak in a scroll_to_bottom — they go
// through `mark_history_updated` which only bumps `history_version`.
use crate::tui::scrolling::TranscriptScroll;
let mut app = create_test_app();
app.transcript_scroll = TranscriptScroll::at_line(7);
app.user_scrolled_during_stream = true;
handle_tool_call_started(
&mut app,
"tid",
"exec_shell",
&serde_json::json!({"command": "ls"}),
);
assert!(
!app.transcript_scroll.is_at_tail(),
"tool-cell start must not yank scroll position to bottom",
);
}
#[test]
fn tool_call_complete_does_not_scroll_when_user_scrolled_away() {
use crate::tui::scrolling::TranscriptScroll;
let mut app = create_test_app();
handle_tool_call_started(
&mut app,
"tid",
"exec_shell",
&serde_json::json!({"command": "ls"}),
);
// After start, user scrolls up.
app.transcript_scroll = TranscriptScroll::at_line(7);
app.user_scrolled_during_stream = true;
handle_tool_call_complete(&mut app, "tid", "exec_shell", &ok_result("output"));
assert!(
!app.transcript_scroll.is_at_tail(),
"tool-cell complete must not yank scroll position to bottom",
);
}
#[test]
fn mark_history_updated_does_not_call_scroll_to_bottom() {
// Behavior pin: future contributors must not add a scroll_to_bottom
// here. The scroll-following logic lives only in `add_message` and
// `flush_active_cell`, both gated on `user_scrolled_during_stream`.
use crate::tui::scrolling::TranscriptScroll;
let mut app = create_test_app();
app.transcript_scroll = TranscriptScroll::at_line(3);
app.user_scrolled_during_stream = true;
app.mark_history_updated();
assert!(
!app.transcript_scroll.is_at_tail(),
"mark_history_updated must not scroll",
);
}