Skip to content

fix(streaming): unpin scroll on small upward motion during streaming (#1731)#1732

Closed
Sanjays2402 wants to merge 1 commit intonesquena:masterfrom
Sanjays2402:fix/1731-upward-scroll-direction-detection
Closed

fix(streaming): unpin scroll on small upward motion during streaming (#1731)#1732
Sanjays2402 wants to merge 1 commit intonesquena:masterfrom
Sanjays2402:fix/1731-upward-scroll-direction-detection

Conversation

@Sanjays2402
Copy link
Copy Markdown
Contributor

Thinking Path

What Changed

static/ui.js — scroll listener inside the #messages container:

tests/test_issue1731_upward_scroll_unpins.py — new regression suite covering:

  • direction tracker (_lastScrollTop) exists and is updated each sample
  • the movedUp flag is computed with a small (~2px) tolerance
  • upward branch sets _scrollPinned=false and resets _nearBottomCount, with no >=2 gate
  • downward / stationary branch preserves both _nearBottomCount=nearBottom?+1:0 and _scrollPinned=_nearBottomCount>=2
  • 250px re-pin dead zone is still present
  • _programmaticScroll early-return still runs before requestAnimationFrame

Net 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 scrollToBottomBtn either, since _scrollPinned stays 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

pytest tests/test_issue1731_upward_scroll_unpins.py \
       tests/test_issue677.py \
       tests/test_issue1360_streaming_scroll_hardening.py \
       tests/test_issue1690_scroll_completion.py \
       tests/test_parallel_session_switch.py -v --timeout=60

→ 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 plain master @ 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.

  • Before fix on: _scrollPinned stays true after the upward scroll, next streaming token snaps the viewport back to the bottom — exactly what the reporter described.
  • After fix on: _scrollPinned flips to false immediately, 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.
  • macOS trackpad momentum case (downward fling that decelerates inside the dead zone): unchanged — falls through the else branch, hysteresis still gates re-pin until 2 consecutive near-bottom samples settle. bug(streaming): auto-scroll still overrides user scroll position on macOS app — #677 regression / edge case #1360 protection intact.

(Before/after screenshots attached below.)

Risks / Follow-ups

  • macOS momentum re-pin (bug(streaming): auto-scroll still overrides user scroll position on macOS app — #677 regression / edge case #1360) — protected. The hysteresis counter and 250px threshold are both unchanged on the downward / stationary path. Verified by tests/test_issue1360_streaming_scroll_hardening.py still passing.
  • Programmatic snap-to-bottom polluting _lastScrollTop — guarded. _programmaticScroll short-circuits before the rAF schedule, so programmatic scrolls never write to _lastScrollTop and can't trigger spurious unpins on the next user wheel event. Asserted by test_programmatic_scroll_guard_still_skips_listener.
  • Single-pixel jitter / rAF reflow — the >2px tolerance absorbs sub-pixel rounding without missing a real wheel/trackpad up-tick.
  • Reload / new session_lastScrollTop initializes to null, first sample skips the upward branch, falls into hysteresis path. Identical behavior to today on first load.
  • Initial render bottom-pin — unchanged. _scrollPinned still defaults to true; the listener only runs on actual scroll events.
  • No new dependencies, no build step changes, no CSS changes, no API changes. Pure listener logic.

Possible follow-up (not in this PR): wire _lastScrollTop reset into scrollToBottom() 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

  • AI-assisted via OpenClaw — Anthropic claude-opus-4-7 (xhigh thinking)
  • Diagnosis and fix authored in collaboration with the user; investigation traces, comment block phrasing, and test scaffolding came from the model. All assertions and code paths reviewed against the existing scroll regression suite before push.

…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]>
@Sanjays2402 Sanjays2402 force-pushed the fix/1731-upward-scroll-direction-detection branch from 2471d4c to f6be60f Compare May 6, 2026 05:50
@Sanjays2402
Copy link
Copy Markdown
Contributor Author

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_scrollPinned stays true (green) after the upward scroll because the symmetric hysteresis kept incrementing the counter inside the 250px dead zone. Subsequent streaming tokens snap the viewport back to the bottom, which is exactly what reporter described. The "scrolled up — new messages below" banner stays hidden because the pin flag never flipped.

before

After (fix toggled on) — _scrollPinned flips to false (red) on the wheel-up, the "scrolled up — new messages below" banner appears, the streamed content keeps appending below without yanking the viewport. Scrolling back into the dead zone re-pins via the original hysteresis so #1360 macOS momentum protection is preserved.

after

(Asset PNGs live on a separate gh-assets-1731 branch on my fork so the actual diff stays code-only.)

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Summary

Reading static/ui.js:1404-1424 at cron-pr-1732 head against origin/master's static/ui.js:1395-1407, the diff cleanly resolves the symptom Cygnus reported in #1731. The pre-fix listener applied the same hysteresis to both directions, so an upward wheel that landed inside the 250px dead zone would unpin on the first sample (counter 0→1, _scrollPinned=false) but re-pin on the very next sample (counter 1→2, _scrollPinned=true), at which point the next streaming token snapped the viewport back. That matches the "scroll up 3 or 4 times with determination" symptom — the user has to outrun the rAF re-pin until they finally escape the 250px window.

Code reference

Master (the bug):

const nearBottom=el.scrollHeight-el.scrollTop-el.clientHeight<250;
_nearBottomCount=nearBottom?_nearBottomCount+1:0;
_scrollPinned=_nearBottomCount>=2;

PR head (static/ui.js:1411-1417):

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; } // #1360

The 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.

Diagnosis

The fix interacts cleanly with the surrounding state:

  • Programmatic scrolls don't pollute the tracker. The early-return at static/ui.js:1409 (if(_programmaticScroll) return) runs before requestAnimationFrame schedules, so scrollIfPinned() and scrollToBottom() (lines 1703-1714) never reach the rAF callback and never touch _lastScrollTop. That's the right invariant — _lastScrollTop is the last user-observed position, and a snap-to-bottom shouldn't reset it.
  • First-wheel-up still unpins via the existing counter path. With _lastScrollTop=null on initial load, movedUp is false on the first sample so the listener falls through to the else branch. But on the first sample the counter is 0→1, so _scrollPinned=false anyway. The bug was only the second consecutive sample re-pinning; that's what the new branch prevents.
  • Bounce / momentum on macOS. The top<_lastScrollTop-2 comparison requires a strict 3px+ upward delta to trigger, so single-pixel rAF jitter or trackpad elastic-bounce at the bottom (which overshoots by ≤2px in practice) won't spuriously unpin. The downward path is byte-for-byte identical to master, so the existing test_issue1360_streaming_scroll_hardening.py momentum guarantees still hold.
  • _scrollPinned=false reset at static/ui.js:175 (the older-message-load path that mutates DOM) is unaffected; it doesn't update _lastScrollTop so the next user scroll re-establishes the baseline naturally.

Test coverage

tests/test_issue1731_upward_scroll_unpins.py is a string-match test against the rAF callback body, matching the existing pattern in test_issue1360_streaming_scroll_hardening.py. The six assertions cover the right shape:

  • direction tracker exists and updates each sample
  • movedUp uses a small (~2px) tolerance, not 0
  • upward branch sets _scrollPinned=false and resets _nearBottomCount, with no >=2 gate
  • downward branch keeps _nearBottomCount=nearBottom?_nearBottomCount+1:0 and _scrollPinned=_nearBottomCount>=2
  • 250px dead zone is preserved
  • _programmaticScroll early-return precedes requestAnimationFrame

The test approach is brittle to refactor (renaming _lastScrollTop_lastTop would break it) but that's consistent with the existing scroll-regression test style, and the trade-off is acceptable for catching the exact regression.

One small observation

The 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 let _scrollPinned=true; at static/ui.js:1400 would localize the tuning. Out of scope for this PR.

Verification

The 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 gh-assets-1731 branch show the scrollToBottomBtn flipping visibility correctly, which is also asserted indirectly via _scrollPinned state at static/ui.js:1418-1419.

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.

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Thanks @Sanjays2402 — this shipped in v0.51.8 (commit 85d0279) as part of a 7-PR full-sweep batch release. Stage rebased your branch onto current master, ran the full pre-release gate (4584 pytest, browser tests, Opus advisor verdict SHIP), and merged via release PR #1737.

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 git pull + restart.

Release notes: https://github.com/nesquena/hermes-webui/releases/tag/v0.51.8

pull Bot pushed a commit to soitun/hermes-webui that referenced this pull request May 6, 2026
pull Bot pushed a commit to soitun/hermes-webui that referenced this pull request May 6, 2026
…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.
pull Bot pushed a commit to soitun/hermes-webui that referenced this pull request May 6, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants