Conversation
…bottom When a user scrolls up during streaming, _messageUserUnpinned is set true and scrollIfPinned() permanently stops auto-following — only scrollToBottom() clears the flag, but scrollIfPinned() is what runs on every streamed chunk. Add a re-pin path in scrollIfPinned() that mirrors the scroll listener's _nearBottomCount debounce: after 2 consecutive near-bottom (250px) streaming chunks without active scroll intent, clear the unpinned flag and resume auto-follow. Also normalize the listener's re-pin threshold from 80px to 250px to match its own nearBottom gate, eliminating a dead zone (81-249px) where the counter could reach 2 but the re-pin would silently fail.
| if(_messageBottomDistance()>250){ _nearBottomCount=0; return; } | ||
| _nearBottomCount=_nearBottomCount+1; | ||
| if(_nearBottomCount<2) return; | ||
| _nearBottomCount=0; | ||
| if(_recentNonMessageScrollIntent()||_recentMessageTouchScrollIntent()) return; | ||
| _messageUserUnpinned=false; | ||
| _scrollPinned=true; |
There was a problem hiding this comment.
_nearBottomCount is shared with the scroll listener's movedDown&&nearBottom path — the "2 chunks" debounce can be reached via mixed sources
The scroll listener (line 4859) also increments _nearBottomCount when the user physically scrolls down while near the bottom. This means the threshold of 2 can be reached by 1 scroll-down event + 1 streaming chunk, not strictly "2 consecutive near-bottom streaming chunks" as described. Concretely: user scrolls down once (listener → _nearBottomCount=1), next chunk arrives (scrollIfPinned() → _nearBottomCount=2) → re-pin fires after a single chunk. In the happy path (user is already near the bottom and not actively scrolling), this cooperative counting is fine, but the debounce semantics differ subtly from the PR description and could re-pin one chunk sooner than intended if a scroll event happens to coincide.
nearBottom already guarantees bottomDistance<250, so the bottomDistance<=250 check is always true inside the movedDown&&nearBottom branch. Replace the if wrapper with a comment explaining why it's unconditional now that thresholds are aligned.
|
Good catch on both — addressed:
|
🔬 Gate certification — RED ⛔ (re-pins on mere near-bottom proximity → yanks a reading user back; breaks 3 scroll-pin regression tests — CORE)Certified head: What I ran (rebased worktree
|
| Gate | Result |
|---|---|
| Rebase onto current master | ✅ git apply clean |
| Codex (reproduce) | SHIP-WITH-FIXES — 1 CORE (passive re-pin at 250px yanks reader); I confirmed + found backing test failures |
| Full pytest suite | 5 failed / 11980 passed — 2 known env flakes (nous, issue4536) + 3 REAL scroll-pin regressions this PR causes |
Findings
⛔ CORE (I + Codex CONFIRMED, + 3 regression tests) — streaming yanks a reader back to bottom after a small scroll-up near the tail (static/ui.js:5526): the new scrollIfPinned() branch clears _messageUserUnpinned after 2 streamed calls whenever _messageBottomDistance() <= 250, then _settleMessageScrollToBottom(false) writes bottom (ui.js:5441). It does NOT require an actual return-to-bottom, and it checks _recentMessageTouchScrollIntent()/_recentNonMessageScrollIntent() but OMITS the existing _recentMessageWheelIntent() (4504) and _recentMessageKeyScrollIntent() (4517) — so a wheel/keyboard scroll-up within 250px of the tail is overridden. Backing regression failures (this PR turns them red):
test_issue4295_scroll_pin_reentry::test_near_bottom_proximity_alone_does_not_repin— asserts the EXACT invariant the PR violates (proximity alone must not re-pin).test_issue3250_upward_scroll_intent_window::test_scroll_if_pinned_respects_sticky_user_unpin— sticky-unpin broken.test_tars_scroll_reset_regressions::test_user_scroll_cancels_delayed_bottom_settling.
Fix (Codex-exact): don't passively re-pin at the 250px near-bottom threshold — require an actual bottom return (strict<=80, matching the prior invariant), and bail/reset_nearBottomCounton recent message-pane wheel/key/scroll intent (_recentMessageWheelIntent(),_recentMessageKeyScrollIntent(),_recentMessageScrollIntent()) before incrementing.
Recommendation to the next agent / author
RED — gate-fail/changes-requested (1 CORE, breaks 3 scroll-pin regression tests): tighten the re-pin to require an actual return-to-bottom (<=80, not 250px near-bottom) and add the missing _recentMessageWheelIntent() + _recentMessageKeyScrollIntent() bails so a wheel/keyboard scroll-up near the tail isn't overridden. The goal (resume auto-follow when the user genuinely returns to bottom) is worth shipping — but it must not re-pin on mere proximity (the #4295 proximity-alone-does-not-repin invariant) or fight a wheel/key reader. Once tightened, the 3 red regression tests should pass and it's a real UX win. Then, since it's visible scroll behavior, Nathan's UX call on the exact resume threshold. concept 4/5 (real annoyance fix; the trigger is too eager). Author @luperrypf (T2). crit=3. (Gate value: the full suite caught it directly — 3 prior-scroll-fix regression tests (#3250/#4295/tars) encode the "proximity alone does not re-pin" invariant this PR breaks; always run the full suite on scroll-behavior changes, the regressions are pre-encoded.)
_Gate-certifier layer (warm-up → gate → release). I do not merge/tag/deploy. Rebased onto current master; the passive-re-pin-at-250px CORE confirmed (ui.js:5526 clears _messageUserUnpinned on proximity without return-to-bottom, omits _recentMessageWheelIntent/recentMessageKeyScrollIntent), backed by 3 failing scroll-pin regression tests (#4295 proximity-alone-does-not-repin, #3250 sticky-unpin, tars delayed-settle); 2 other failures are known nous/issue4536 flakes. Cert valid for sha:ccbfd9d1.
release #5544: re-pin auto-scroll at true bottom after scroll-to-bottom
…ll-to-bottom A single scroll-up during streaming set _messageUserUnpinned=true and scrollIfPinned() then permanently stopped auto-follow (only scrollToBottom() cleared it) — a permanent auto-follow lockout even after the user returned to the bottom. scrollIfPinned() now re-pins, but ONLY when the reader has genuinely reached the true-bottom tail (<=80px) AND shows no active scroll intent (wheel/key/touch/non-message), reusing the listener's _nearBottomCount debounce. Proximity alone (the ~250px nearBottom band) must never re-pin — that is the nesquena#4295 invariant. Restores the listener's <=80px true-bottom gate too. Co-authored-by: luperrypf <luperrypf@users.noreply.github.com>
|
Shipped in v0.51.862 — thanks @luperrypf! 🎉 Your auto-scroll re-pin fix is live. The permanent auto-follow lockout is gone: once you've scrolled up during a streaming reply, returning to the true bottom of the transcript (within ~80px) now re-engages auto-scroll so the view follows the stream again — previously only starting a new turn re-enabled it. The re-pin is deliberately conservative: it only fires at the true-bottom tail and bails on any active scroll intent (wheel / keyboard / touch / scrollbar drag), so reading back through history mid-stream never yanks you down — the #4295 invariant is preserved. The listener's Gate: rebuilt on current master and re-gated — Codex clean (regression), Fable SHIP-UX (matches ChatGPT's scroll-away-during-stream behavior; the 80px resume is the right call), 20/20 scroll-regression tests, full suite green, plus a live browser drive on a seeded 40-message session that confirmed both behaviors (no yank while reading, follow re-engages at bottom) with zero console errors. Merged via release PR #5580 with your authorship preserved. Appreciate the sharp fix to a genuinely annoying regression. |
…odex round-3) Codex round-3 BRICK: a bare relative 'login' Location from /session/login resolves back to /session/login — which is NOT in PUBLIC_PATHS and NOT the /login route (login page is served only at the public /login), so check_auth() fires again → infinite redirect. Fix: for a session-scoped login-shaped path, emit '../login' (verified via urljoin: /session/login → /login; subpath /hermes/session/login → /hermes/login). New _safe_login_inner_next() preserves a legitimate NON-login inner next across the bounce (drops login-self / nested / unsafe values), so a real post-login destination survives. Codex round-3 SILENT (scrollIfPinned) was a STALE-BASE artifact: my branch was 6 commits behind master and lacked #5544's re-pin code — NOT a removal by me. Resolved by rebasing onto current master (picks up #5544; 264 scroll tests green). Also restored the is_auth_enabled() short-circuit at the top of check_auth() (a patch mid-edit had briefly dropped it — caught + restored before commit). Tests: +check_auth ../login resolution + _safe_login_inner_next drop/preserve cases. 18 issue+#5021 tests, 264 scroll tests, 593 auth-slice tests green (the 3 test_issue803 failures are a pre-existing hand-slice isolation artifact — pass in isolation on both master and this branch; not caused by this change). ruff clean. Refs #5578.
When a user scrolls up during streaming,
_messageUserUnpinnedis settrueandscrollIfPinned()permanently stops auto-following — onlyscrollToBottom()clears the flag, butscrollIfPinned()is what runs on every streamed chunk.Fix: Add a re-pin path in
scrollIfPinned()that mirrors the scroll listener's_nearBottomCountdebounce:_recentNonMessageScrollIntent,_recentMessageTouchScrollIntent)Also normalize the listener's re-pin threshold from 80px to 250px to match its own
nearBottomgate, eliminating a dead zone (81-249px) where the counter could reach 2 but the re-pin would silently fail.Before: one scroll-up → permanent auto-follow lockout
After: scroll back near bottom → 2 chunks confirm → auto-follow resumes