fix(scroll): preserve user_scrolled_during_stream lock across resolve

Live repro: in a session producing content rapidly (sub-agent
running, multiple tool calls), the user scrolls up to read earlier
output. Their scroll position briefly takes effect, then snaps back
to the live tail when the next stream chunk arrives. Symptom is
"scrolling is broken / takes over instead of the transcript".

Root cause in `crates/tui/src/tui/widgets/mod.rs:188-210`:

* The user's mouse-scroll-up sets `transcript_scroll = at_line(N)`
  and `user_scrolled_during_stream = true`.
* During render, `resolve_top` clamps the state against
  `max_start = total_lines.saturating_sub(visible_lines)`. If
  `max_start < N` (transcript shrunk between scrolls and render —
  e.g., a sub-agent in-progress card collapsed into a smaller
  finished card, or the content briefly fits in one screen),
  `resolve_top` returns `Self::to_bottom()` (TAIL_SENTINEL).
* `is_at_tail()` on the post-resolve state returns `true`.
* The auto-clear at line 208 fires →
  `user_scrolled_during_stream = false`.
* Next `add_message` / sub-agent envelope sees `is_at_tail() &&
  !user_scrolled_during_stream` and calls `scroll_to_bottom()`. The
  user is yanked off their position mid-read.

`scrolled_by` has the same trapdoor: when `total_lines <= visible_
lines` it returns `to_bottom()` regardless of scroll direction
(line 145-148 in scrolling.rs). A user scroll-up while content
fits in one screen produces `to_bottom()` → `is_at_tail()` true →
auto-clear → next chunk yanks.

The fix
=======

Snapshot whether the user's PRIOR state was deliberately tail
(`is_at_tail()` BEFORE `resolve_top`), and only clear the lock
when:

1. Prior state was already TAIL_SENTINEL (deliberate, set by
   `scrolled_by` reaching `max_start` while scrolling DOWN, or by
   `scroll_to_bottom()`).
2. AND `total_lines > visible_lines` (so "tail" is meaningful —
   if the whole transcript fits, "is_at_tail" is trivially true
   and clearing the lock would yank the user back to bottom on
   the next chunk despite their explicit scroll-up).

This preserves all the legitimate clear paths:
* `TurnComplete` event clears the lock at the per-turn boundary
  (`ui.rs:879`).
* User invokes `scroll_to_bottom()` explicitly via key/menu
  (`app.rs:2459`).
* User scrolls down enough that `scrolled_by` reaches `max_start`
  in a transcript with real scroll room — state goes through
  `to_bottom()` BEFORE resolve, so `was_explicit_tail = true` and
  the lock clears.

What it stops:
* Render-time resolve clamping `at_line(N)` to tail when content
  shrunk doesn't quietly revoke the user's intent.
* `scrolled_by` collapsing a scroll-up to `to_bottom()` when
  content briefly fits in one screen no longer triggers the
  auto-clear (the prior state wasn't tail).

Verified locally:

* `cargo fmt --all -- --check` clean.
* `cargo clippy --workspace --all-targets --all-features --locked
  -- -D warnings` clean.
* `cargo test --workspace --all-features --locked` — 2038 passed,
  2 ignored, 0 failed (a snapshot::repo flake unrelated to scroll;
  passes in isolation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-05-04 23:35:39 -05:00
parent 1131e7a7b0
commit 5c72e5f463
+19 -1
View File
@@ -195,6 +195,17 @@ impl ChatWidget {
}
let max_start = total_lines.saturating_sub(visible_lines);
// v0.8.11 hotfix: snapshot whether the user's prior scroll state
// was *deliberately* tail BEFORE we resolve. `resolve_top` clamps
// out-of-range `at_line(N)` to `to_bottom()` (e.g. when content
// shrunk so `max_start < N`), and `scrolled_by` returns
// `to_bottom()` when the whole transcript fits in one screen
// even if the user just scrolled up. Either case would fool a
// post-resolve `is_at_tail()` check into thinking the user is
// tracking the tail and silently revoke `user_scrolled_during_
// stream` — the next stream chunk would then yank them back to
// bottom mid-read.
let was_explicit_tail = app.viewport.transcript_scroll.is_at_tail();
let (scroll_state, top) = app
.viewport
.transcript_scroll
@@ -205,7 +216,14 @@ impl ChatWidget {
// again until they explicitly scroll up. Without this clear, content
// piles up off-screen below the visible area and the view appears
// frozen at the moment they returned to bottom.
if app.viewport.transcript_scroll.is_at_tail() {
//
// Only clear the lock when the user's INTENT was tail (their
// stored state was already `to_bottom()` before resolve), AND
// when the transcript actually has scrolling room to talk about
// — if everything fits in one screen, "tail" is trivially true
// and clearing here would yank the user back to bottom on the
// next chunk even though they explicitly scrolled up.
if was_explicit_tail && total_lines > visible_lines {
app.user_scrolled_during_stream = false;
}