fix(streaming): unpin scroll on small upward motion during streaming (#1731)#1732
fix(streaming): unpin scroll on small upward motion during streaming (#1731)#1732Sanjays2402 wants to merge 1 commit intonesquena:masterfrom
Conversation
…esquena#1731) The streaming scroll listener applied hysteresis symmetrically: an upward scroll that landed inside the 250px near-bottom dead zone still reported the user as near the bottom, so _nearBottomCount kept incrementing and _scrollPinned stayed true. The next streaming token snapped the user back to the bottom. The user effectively had to escape the 250px zone in one fling to read earlier output. The 250px dead zone itself is required by nesquena#1360 / nesquena#677 (macOS small window + trackpad momentum re-pin protection) so the fix is direction detection, not threshold relaxation: track _lastScrollTop and unpin immediately on an explicit upward movement (>2px decrease), while downward / stationary movement keeps the original hysteresis re-pin path so the macOS momentum protection is preserved. Programmatic scrolls are still masked by the existing _programmaticScroll guard, so scrollToBottom() never updates _lastScrollTop and never spuriously unpins. Adds tests/test_issue1731_upward_scroll_unpins.py covering: direction tracker exists, upward branch sets _scrollPinned=false and resets the counter without hysteresis, downward branch preserves the >=2 hysteresis re-pin requirement, the 250px threshold remains, and the _programmaticScroll bail still runs before the rAF schedule. Closes nesquena#1731. Co-Authored-By: Potato (OpenClaw assistant) <[email protected]>
2471d4c to
f6be60f
Compare
|
Before / After (manual repro) Repro setup is a vendored copy of the listener with a fix toggle. Same input on both: streaming starts, ~1.5s of tokens land, one synthesized 100px wheel-up event mid-stream, then 800ms more streaming, then the screenshot. Before — After (fix toggled on) — (Asset PNGs live on a separate |
SummaryReading Code referenceMaster (the bug): const nearBottom=el.scrollHeight-el.scrollTop-el.clientHeight<250;
_nearBottomCount=nearBottom?_nearBottomCount+1:0;
_scrollPinned=_nearBottomCount>=2;PR head ( const top=el.scrollTop;
const nearBottom=el.scrollHeight-top-el.clientHeight<250;
const movedUp=_lastScrollTop!==null && top<_lastScrollTop-2;
_lastScrollTop=top;
if(movedUp){ _nearBottomCount=0; _scrollPinned=false; } // #1731
else { _nearBottomCount=nearBottom?_nearBottomCount+1:0; _scrollPinned=_nearBottomCount>=2; } // #1360The asymmetry is the right shape: re-pin still requires two consecutive near-bottom samples (the #1360 macOS momentum protection), but unpin happens immediately on any user-driven upward delta >2px. DiagnosisThe fix interacts cleanly with the surrounding state:
Test coverage
The test approach is brittle to refactor (renaming One small observationThe 2px tolerance and 250px dead zone are both hard-coded magic numbers. Not a blocker — they match the existing pattern in this file — but if you ever want to dial them per-platform (mobile Safari vs WKWebView vs desktop Chrome have different rAF/scroll-rounding behaviors), a named constant pair near VerificationThe PR description's manual repro (vendored listener with fix toggle, simulated 100px wheel-up mid-stream) lines up with the code path I traced above. The before/after screenshots on the LGTM. The fix is surgical, the asymmetry is justified by the linked context (#1360 / #677 / #1469), and the regression test pins down the invariants that matter. |
|
Thanks @Sanjays2402 — this shipped in v0.51.8 (commit GitHub didn't auto-close because the merge commit only references the squash-merged stage branch, not your fork's commit directly — closing manually for hygiene. Live now on https://get-hermes.ai/ and on existing installs after Release notes: https://github.com/nesquena/hermes-webui/releases/tag/v0.51.8 |
…pward motion during streaming (nesquena#1731) by @Sanjays2402
…ollow-up) Opus advisor on stage-302 (nesquena#1732 verification Q5) flagged that _lastScrollTop is module-global and persists across chat switches. When the user switches sessions, the new chat's first user scroll compares against the previous chat's last scrollTop. If the previous was deep- scrolled (e.g. 5000) and the new chat starts at top=0, scrolling down to 100 would evaluate as movedUp=true → false-unpin, blocking auto- scroll on the new chat's first incoming token. Fix: expose _resetScrollDirectionTracker() from static/ui.js on window so static/sessions.js loadSession() can reset _lastScrollTop=null when S.session is reassigned. The scroll listener's existing _lastScrollTop!==null guard then handles the first sample after reset correctly (no false-trigger on the very first scroll event in the new chat). Absorb-in-release per Opus stage-302 verdict — small, defensive, ≤20 LOC.
…p + test-isolation fix Constituent PRs: - nesquena#1725 (@Michaelyklam) — simplify compact Activity row summary - nesquena#1726 (@Michaelyklam) — delegate generic provider catalogs to Hermes CLI (slice of nesquena#1240) - nesquena#1727 (@Michaelyklam) — link Claude Code OAuth in onboarding (closes nesquena#1362) - nesquena#1728 (@starship-s) — preserve profile context when starting chats - nesquena#1729 (@Michaelyklam) — persist compact Activity disclosure state - nesquena#1730 (@Michaelyklam) — prevent sticky sidebar hover drag state - nesquena#1732 (@Sanjays2402) — unpin scroll on small upward motion during streaming (closes nesquena#1731) Plus 2 in-stage absorbed fixes: - test-isolation fix: monkeypatch.setattr(config, 'cfg', X) survives PR nesquena#1728's path/mtime-aware get_config() reload. Mandatory before tag (Opus stage-302). - Opus SHOULD-FIX #1: _lastScrollTop reset on session switch (nesquena#1732 follow-up). Tests: 4537 → 4584 passing (+47). 0 regressions. Full suite ~128s. Stably green. Pre-release verification: - All 7 PRs CI-green individually + rebased onto master - pytest 4584 passed, 0 failed (multiple runs) - node -c clean on all 4 modified .js files - 11/11 browser API endpoints PASS on isolated port 8789 - 20 QA tests via webui_qa_agent.sh PASS - Opus advisor: SHIP, 5/5 verification clean, 0 MUST-FIX, 1 SHOULD-FIX absorbed (_lastScrollTop reset), 1 SHOULD-FIX deferred (nesquena#1736 — _clear_anthropic_env_values race, onboarding-time-only) Closes nesquena#1362, nesquena#1731.


Thinking Path
_nearBottomCount >= 2hysteresis) for both directions. That's correct for re-pinning (entering the dead zone), but for unpinning it stranded users who scrolled up by less than 250px — every upward sample still landed inside the near-bottom region, so the counter kept incrementing and_scrollPinnedstayed true. The next streaming token then snapped them backtests/test_issue1360_streaming_scroll_hardening.py::test_scroll_repin_dead_zone_is_wider_for_mac_app_windows) so the fix has to make the asymmetry of the two directions explicit rather than relax the threshold_lastScrollTopin the listener closure: an explicit upward delta (>2px) unpins immediately and resets the counter; downward / stationary samples keep the original hysteresis re-pin path so bug(streaming): auto-scroll still overrides user scroll position on macOS app — #677 regression / edge case #1360 stays protected_programmaticScroll, so_lastScrollToponly ever sees user-driven motion andscrollToBottom()cannot accidentally unpinWhat Changed
static/ui.js— scroll listener inside the#messagescontainer:_lastScrollToptracker (closure-scoped, not exported)movedUp = _lastScrollTop !== null && top < _lastScrollTop - 2_lastScrollTop = topevery sample (only when the listener actually runs, i.e. user-driven scrolls)movedUp→_nearBottomCount = 0; _scrollPinned = false;(no hysteresis, immediate unpin) — bug(streaming): small upward scrolls during streaming are overridden until user escapes near-bottom threshold #1731_nearBottomCount = nearBottom ? +1 : 0; _scrollPinned = count >= 2;path — bug(streaming): auto-scroll still overrides user scroll position on macOS app — #677 regression / edge case #1360tests/test_issue1731_upward_scroll_unpins.py— new regression suite covering:_lastScrollTop) exists and is updated each samplemovedUpflag is computed with a small (~2px) tolerance_scrollPinned=falseand resets_nearBottomCount, with no>=2gate_nearBottomCount=nearBottom?+1:0and_scrollPinned=_nearBottomCount>=2_programmaticScrollearly-return still runs beforerequestAnimationFrameNet change: +23 / -7 in
static/ui.js, +159 in tests. Most of the diff is updated comments; the live-code delta is ~10 lines.Why It Matters
Reporter (Cygnus, Discord, May 5) hit this on every small scroll-up during a streaming reply. It's an interaction bug, not a visual one — and small enough that it doesn't trigger the
scrollToBottomBtneither, since_scrollPinnedstays true. The user has no obvious escape hatch except a fling-scroll past the entire 250px zone. Direction detection makes "I want to read what you just wrote" work the way it should.Verification
Automated
→ 58 passed (6 new, 52 pre-existing scroll regression tests, all green).
Full repo
pytest tests/is also clean on this branch except for 10 pre-existing failures on master (test_ctl_script.py,test_issue609.py,test_sprint3.py— environment / sandbox-dependent, unrelated to this change). Verified by stashing the diff and re-running on plainmaster @ d8cd556— same 10 fail there too.Manual
Repro page (vendored copy of the listener with a fix toggle): simulated streaming + a 100px upward wheel mid-stream.
_scrollPinnedstaystrueafter the upward scroll, next streaming token snaps the viewport back to the bottom — exactly what the reporter described._scrollPinnedflips tofalseimmediately, the "scrolled up" banner appears, subsequent tokens append below without yanking the viewport. Scrolling back into the dead zone (downward, 2 consecutive near-bottom samples) re-pins as before.(Before/after screenshots attached below.)
Risks / Follow-ups
tests/test_issue1360_streaming_scroll_hardening.pystill passing._lastScrollTop— guarded._programmaticScrollshort-circuits before the rAF schedule, so programmatic scrolls never write to_lastScrollTopand can't trigger spurious unpins on the next user wheel event. Asserted bytest_programmatic_scroll_guard_still_skips_listener._lastScrollTopinitializes tonull, first sample skips the upward branch, falls into hysteresis path. Identical behavior to today on first load._scrollPinnedstill defaults totrue; the listener only runs on actual scroll events.Possible follow-up (not in this PR): wire
_lastScrollTopreset intoscrollToBottom()for the rare case where a programmatic snap is followed immediately by a user wheel — currently safe via_programmaticScroll, but could be a defensive nicety. Out of scope here.Model Used
claude-opus-4-7(xhigh thinking)