Skip to content

fix(chat): prevent first response jump from snapping back - #6621

Merged
nesquena-hermes merged 9 commits into
nesquena:masterfrom
pxxD1998:fix/jump-to-answer-scroll-settle
Aug 14, 2026
Merged

nesquena-hermes merged 9 commits into
nesquena:masterfrom
pxxD1998:fix/jump-to-answer-scroll-settle

Conversation

@pxxD1998

@pxxD1998 pxxD1998 commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

Thinking Path

  • Session load intentionally settles the transcript at the bottom.
  • The per-response jump button intentionally moves the reader to the start of that response.
  • On the first click after loading a session, a previously scheduled bottom-settle callback could still run after the jump.
  • That stale callback reclaimed scroll ownership and snapped the transcript back to the bottom.
  • The fix is to cancel pending bottom settling and mark the transcript as explicitly unpinned before any jump target path can return.

What Changed

  • Cancel pending load-time bottom settling at the shared entry point of jumpToTurnQuestion().
  • Mark the message pane as reader-unpinned before both visible-target and virtualized-target jump paths.
  • Add a Node-backed regression test that schedules the stale bottom callback, performs the first response jump, lets pending timers run, and verifies that the response position is retained.

Why It Matters

The first click on Jump to response should be reliable. Previously, readers could see the correct response start briefly and then be returned to the bottom, while later clicks appeared to work because the stale load-time settle had already expired.

Contract Routing

  • Contract family: transcript navigation and message-pane scroll ownership.
  • Existing product rule preserved: a loaded session may initially settle at the bottom, while an explicit per-response jump takes the reader to that response start.
  • This PR fixes ownership ordering; it does not redefine the destination, persistence model, or UI layout.

Verification

Regression proof

On current origin/master, before the fix:

1 failed
scrollPinned: true
messageUserUnpinned: false
snappedBackToBottom: true
bottomSettleToken: 0

After the fix:

53 passed in 4.11s

Command:

./scripts/test.sh \
  tests/test_jump_to_answer_scroll_settle.py \
  tests/test_issue3319_pinned_scroll_jump.py \
  tests/test_issue1690_scroll_completion.py \
  tests/test_issue1360_streaming_scroll_hardening.py \
  tests/test_issue500_message_list_virtualization.py \
  tests/test_issue3851_jump_to_question_mobile.py \
  tests/test_issue2246_question_jump.py -q

Additional checks:

node --check static/ui.js: passed
ruff check tests/test_jump_to_answer_scroll_settle.py: passed
npm run lint:runtime: passed
git diff --check: passed

Real browser check

The browser-loaded jumpToTurnQuestion() was compared byte-for-byte with this candidate before the check.

before click: bottom distance = 0 px
500 ms after first click: bottom distance = 226 px, scrollTop = 5977
2.3 s later: bottom distance = 226 px, scrollTop = 5977
console errors: 0

Before / after interaction evidence

Each recording is a synchronized side-by-side comparison: left = before (0a401597594575d5650a755d1228b7de5a87544e), right = this PR (2015ab9c2b15a1cfdba956ec386a79dedece1952). Both sides start at the bottom and perform the same first Jump to response action. The before side returns to the bottom after the load-time settle runs; the fixed side retains the response start.

The harness clicked 375 ms after the real control became visible, then sampled the DOM at 590 ms and 2,690 ms after the click. It used eight synthetic messages, separate state roots and ports for each revision, and no provider or LLM call. The red/green status labels are evidence-only overlays injected by the recording harness; they are not product UI.

Desktop — 1440 × 900

Desktop before and after: the old version snaps back to the bottom while the fixed version stays at the response

Narrow — 900 × 900

Narrow before and after: the old version snaps back to the bottom while the fixed version stays at the response

Mobile — 430 × 860, touch enabled

Mobile before and after: the old version snaps back to the bottom while the fixed version stays at the response

Viewport Before at 590 ms Before at 2,690 ms Fixed at 2,690 ms
Desktop 1,783 px from bottom 0 px (snapped back) 1,812 px from bottom
Narrow 2,303 px from bottom 0 px (snapped back) 2,400 px from bottom
Mobile 2,883 px from bottom 0 px (snapped back) 3,101 px from bottom

All six source recordings passed the behavioral verdict, full media decode, sampled-frame review, and privacy review; no meaningful browser console errors were recorded.

The full test suite was not run locally; the focused and neighboring 53-test set above was run, and the complete Python-version matrix is left to repository CI.

Risks / Follow-ups

  • Low scope: one shared scroll-ownership entry point and one regression test.
  • Initial session load still settles at the bottom.
  • Streaming/completion, virtualized transcript, pinned-scroll, mobile jump, and existing response-jump neighboring tests remain green.
  • No follow-up feature or migration is required.

Documentation

No documentation change is needed. This restores the existing response-jump contract without changing controls, setup, configuration, public APIs, or the intended interaction flow.

Release Note

Fixed the first Jump to response click after loading a session so it no longer snaps back to the bottom.

Model Used

AI-assisted implementation and verification:

  • Provider: OpenAI Codex
  • Model: gpt-5.6-sol
  • Reasoning effort: high
  • Notable tool use: repository tests, Git/GitHub CLI, Node syntax/runtime lint, and real browser automation

@nesquena-hermes nesquena-hermes added the size:M Medium PR (≤10 files, ≤250 LOC) label Jul 30, 2026
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Gate certification — RED ⛔

Exact contributor head: 2015ab9c2b15a1cfdba956ec386a79dedece1952
Frozen current master / tested base: 0a401597594575d5650a755d1228b7de5a87544e
Rebase: clean no-op; tested tree remained the exact contributor head.

Threat scan is CLEAN (score 0). The focused JS/scope/ruff slice passed 6/6 and node --check static/ui.js passed. The new regression is genuine: deleting only the new cancellation/unpin block makes test_first_jump_to_answer_cancels_pending_load_time_bottom_settle fail with the stale callback snapping to bottom. The PR fixes the reported first-jump snap-back.

The exact head is nevertheless not ship-ready because it introduces a silent active-session refresh dead state:

  • static/ui.js:1640-1643 cancels bottom settling correctly, but then sets sticky reader ownership unconditionally.
  • On a non-scrollable or near-tail transcript, the jump destination is at most 80px from the tail. static/sessions.js:5834 therefore defers poll/SSE/focus/visibility reconciliation, while static/ui.js:6123 keeps the bottom control hidden and only scrollToBottom() at static/ui.js:6923 flushes the deferral. With no scroll range and no visible recovery control, external messages remain stale until another action resets ownership.

I reproduced this independently three ways:

  1. A reviewer-owned production-function A/B at scroll ranges 0, 79, 81, and 400px: the candidate cancelled the stale settle but left all four cases reader-unpinned and deferred idle-reconcile; current master remained pinned and reconciled.
  2. Real Chromium on the exact candidate at 1440×900 and 390×844: both short transcripts had scrollHeight === clientHeight, clicking the real Response jump set readerUnpinned=true, the real idle scheduler recorded idle-reconcile, and the bottom control remained display:none. Both captures were vision-verified as the real chat after rejecting and replacing initial onboarding-obscured shots.
  3. Codex independently reproduced the same production consumer failure and returned SHIP ONLY WITH FIXES. Fable returned SHIP-WITH-UX-FIXES for the same no-recovery state. Real Opus 4.8 returned APPROVE, but acknowledged and reproduced the dead state, then classified it as non-blocking because wheel-up can already reach the same state on master. I reject that classification: this diff adds a new deterministic trigger to the ordinary Response action, and the exact candidate-vs-master A/B proves that trigger is introduced by this PR. A pre-existing alternate trigger does not exonerate a new regression path.

Full serial sandbox suite: candidate 13,829 passed / 8 failed / 2 errors; exact frozen-master control 13,828 passed / the identical 8 failed / 2 errors. The candidate adds its one passing regression and has zero suite-failure delta, but the suite remains honestly non-green; the independently reproduced product blocker above is PR-owned.

Required fix

  1. Keep _cancelBottomSettle() unconditional.
  2. Set _scrollPinned=false, _messageUserUnpinned=true, and _nearBottomCount=0 only when the resolved, clamped jump destination is more than the production 80px tail threshold away. A merely scrollable pane is not sufficient because a response target can still resolve within 80px of the tail.
  3. Add biting geometry/reconciliation regressions for a non-scrollable pane, a ≤80px near-tail destination, and a >80px reader-owned destination. Exercise the production active-session refresh guard and prove the first two do not defer while the last does.

A substantive re-push needs a fresh exact-head full gate. No merge, tag, deploy, close, or contributor-branch write was performed.

@nesquena-hermes nesquena-hermes added the gate-fail Gate found blocking issue(s); fix-spec in comment; awaiting fix/re-push label Jul 30, 2026
@pxxD1998

Copy link
Copy Markdown
Contributor Author

Implemented the requested destination-aware ownership fix in 5c2b9e212b7fa5c4eb46af3aaf92be02980e4430.

What changed

  • Kept _cancelBottomSettle() unconditional.
  • Resolve and clamp the visible DOM or virtualized jump destination before changing scroll ownership.
  • Set _scrollPinned=false, _messageUserUnpinned=true, and _nearBottomCount=0 only when the resolved destination is more than the production 80px tail threshold away.
  • Added production-function geometry/reconciliation coverage for scroll ranges 0, 79, 81, and 400px, including the real refreshActiveSessionIfExternallyUpdated('idle-reconcile') guard.

Verification on exact head

  • Focused scroll/refresh slice: 70 passed.
  • node --check static/ui.js: passed.
  • Ruff for the two touched Python test files: passed.
  • ESLint runtime guard: passed.
  • git diff --check: passed.
  • Real Chromium DOM A/B using the exact candidate functions:
    • 0px and 79px: remained pinned; refresh API ran; no deferral.
    • 81px and 400px: reader-owned; idle-reconcile deferred.
    • all four cancelled the stale settle and reached the requested target.

Full local serial suite result was 13,863 passed / 123 skipped / 1 xfailed / 2 xpassed / 1 failed. The one failure was test_issue4685_post_compression_context_metering.py::test_post_compression_estimate_uses_compressor_budget_counter_without_metadata_estimators, caused by full-suite module-order contamination while importing the external local agent.context_compressor; the exact test passes in isolation on both this head and a clean origin/master worktree. This PR does not modify agent/, compression, or Python API code.

@nesquena-hermes nesquena-hermes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-gate — changes requested

Exact contributor head: 5c2b9e212b7fa5c4eb46af3aaf92be02980e4430

The re-push fixes the synchronous destination predicate from the prior gate, but the same stale-refresh state is still reachable through the real smooth-scroll lifecycle.

Blocking finding

jumpToTurnQuestion() now correctly avoids setting sticky reader ownership when its predicted, clamped destination is within 80px of the tail. It then calls scrollIntoView({behavior:'smooth'}) without marking that animation as programmatic or otherwise owning its scroll events.

The production #messages scroll listener subsequently sees an upward animation frame as movedUp and sets:

_scrollPinned = false;
_messageUserUnpinned = true;
_nearBottomCount = 0;

That recreates the prior dead state at a near-tail destination: the bottom control remains hidden because the distance is <=80, while refreshActiveSessionIfExternallyUpdated('idle-reconcile') defers because _messageUserUnpinned is true.

I reproduced this with a reviewer-owned composed harness using the exact production jumpToTurnQuestion, scroll-listener RAF body, and active-session refresh guard. At a 79px scroll range, the mandatory sandbox run failed with:

{"bottomDistance":79,"cancelCalls":2,"cueShown":false,"deferredReason":"idle-reconcile","messageUserUnpinned":true,"nearBottomCount":0,"refreshResult":"skipped","scrollPinned":false,"scrollTop":0}

Threat scan: CLEAN. The contributed exact-head slices remain green (5 passed and 7 passed), but their scrollIntoView mock assigns scrollTop synchronously and dispatches no production scroll-listener lifecycle, so they cannot detect this regression.

Required fix

  1. Keep _cancelBottomSettle() unconditional.
  2. Give the response jump a generation/programmatic-scroll owner that covers the full native smooth-scroll lifecycle, so its animation frames cannot enter the listener's manual movedUp branch.
  3. Reconcile the actual final clamped destination after scrolling/layout. Preserve the pre-jump sticky state at <=80px; claim reader ownership only when the resolved destination is >80px from the tail.
  4. Do the same reconciliation for the virtualized estimate-to-render path rather than permanently claiming from the pre-render estimate.
  5. Add a regression that drives visible assistant, visible user-row, and virtualized jumps through the production scroll listener before calling the production refresh guard. Cover 0/79/80/81/>80px, _nearBottomCount, and bottom-control visibility.

No merge, tag, deploy, close, or contributor-branch write was performed.

@nesquena-hermes nesquena-hermes added changes-requested Maintainer left detailed feedback requesting changes; PR is waiting on author to address size:L Large PR (>10 files or >250 LOC) and removed changes-requested Maintainer left detailed feedback requesting changes; PR is waiting on author to address size:M Medium PR (≤10 files, ≤250 LOC) labels Jul 30, 2026
@pxxD1998

Copy link
Copy Markdown
Contributor Author

Addressed the latest smooth-scroll lifecycle review in a2069be69791a7f6671d872a5864b4f7c307a226.

  • Response jumps now hold a generation- and session-scoped programmatic owner through the full native smooth-scroll lifecycle.
  • Final ownership is reconciled from the actual clamped destination: <=80px preserves the prior sticky state, while >80px claims reader ownership.
  • Session changes and real wheel, touch, keyboard, or scrollbar input cancel the stale jump owner.
  • Visible assistant, visible user-row, and virtualized paths are covered through the production scroll listener and active-session refresh guard at 0/79/80/81/400px.

Verification on the exact head:

  • Focused and adjacent local slice: 30 passed.
  • node --check static/ui.js: passed.
  • Ruff and git diff --check: passed.
  • GitHub checks: 21/21 successful, 0 pending, 0 failed.

Please re-gate this exact head when ready.

@cutter-sh

cutter-sh Bot commented Jul 31, 2026

Copy link
Copy Markdown

🎬 Cutter preview — PR #6621

Jump to response
Jump to response — Jump to response holds the target message in view instead of snapping back to the bottom.

@pxxD1998
pxxD1998 force-pushed the fix/jump-to-answer-scroll-settle branch from a2069be to 0e528c0 Compare August 9, 2026 17:43
@greptile-apps

greptile-apps Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

Restores reliable response-jump scrolling after session load.

  • Cancels stale bottom-settle work before visible and virtualized jump paths.
  • Adds temporary jump-scroll ownership and reconciles pin state after smooth scrolling.
  • Handles user takeover, session replacement, streaming updates, and explicit bottom navigation during a jump.
  • Adds Node-backed regression coverage and updates neighboring scroll tests.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
static/ui.js Coordinates bottom-settle cancellation and temporary scroll ownership so response jumps retain their destination across rendering and streaming updates.
tests/test_jump_to_answer_scroll_settle.py Adds focused Node-backed coverage for stale settle callbacks, smooth-scroll reconciliation, user takeover, session changes, and streaming interactions.
tests/test_issue2246_question_jump.py Updates source-level assertions for the virtualized response-jump path.
tests/test_issue4856_android_scroll_regression.py Expands the inspected source range to accommodate the updated message-scroll intent logic.

Reviews (2): Last reviewed commit: "fix(scroll): widen jump-owner takeover t..." | Re-trigger Greptile

Comment thread static/ui.js
Comment on lines +1653 to +1654
const virtualTarget=clampTargetScrollTop(_messageVirtualScrollTopForVisibleIdx(visWithIdx, visibleIdx, container));
container.scrollTop=virtualTarget;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Virtualized middle targets stay unmounted

When a response lies in the unrendered middle of a long virtualized transcript, clamping its full-transcript offset to the current DOM range makes the preserve-scroll renders continue selecting windows that exclude the requested row, leaving the viewport at the bottom instead of jumping to that response.

pxxD1998 and others added 9 commits August 14, 2026 03:56
Cancel pending load-time bottom settling before an explicit response jump takes scroll ownership. Add a regression test covering the first-click race.
Native smooth-scroll frames from response jumps could reach the manual scroll listener and claim reader ownership before the final destination was known.

- own response-jump scrolling with a generation- and session-scoped lifecycle
- reconcile sticky ownership from final 80px tail geometry
- preserve current low-delta wheel takeover semantics when integrating with master
- cover visible assistant, user-row, and virtualized paths through the production scroll listener
…n explicit End (nesquena#6621 gate fixes)

Codex gate found two defects in the jump-scroll ownership mechanism:
- Jump during an active stream snapped back to bottom on the next token: the
  owner suppressed the scroll listener but left the pre-jump pinned state, so
  scrollIfPinned() reclaimed the bottom. Now _beginMessageJumpScroll unpins for
  the ownership window and scrollIfPinned() no-ops while an owner exists.
- An explicit End click could be undone by the pending jump reconcile restoring
  the stale unpinned snapshot. scrollToBottom() now cancels the active jump
  owner first; _cancelMessageJumpScroll restores the preserved snapshot so a
  non-reconcile cancel doesn't leak the transient unpinned state.
…ionId (nesquena#6621 brick-class scope-undef gate)

The PR's _messageJumpSessionId() referenced a bare 'currentSid' global that
does not exist in ui.js (everywhere else it's a local const from
S.session.session_id). test_static_js_scope_undef flagged it brick-class
(nesquena#3696). The S.session.session_id fallback already IS the canonical accessor;
removed the dead first line and updated the harness to drive the session-change
case via S.session.session_id.
…quena#6621)

Re-gate found three more state-transition edge cases from the temp-unpin window:
- wheel-up interrupting an active jump owner after the programmatic latch
  expires now explicitly establishes the unpinned reader-owned state (was
  cancelling the jump but leaving pinned -> next token snapped to bottom).
- _resetStreamScrollFollow() now cancels the jump owner FIRST, before its
  pinned-state assignments, so a stream starting mid-jump can't have its pin
  undone by the snapshot restore -> auto-follow stays enabled.
- _finishMessageJumpScroll() flushes a deferred external-session refresh after
  reconciliation when the terminal state is pinned to the tail, so a refresh
  deferred during the temp-unpin window isn't stranded.
…indow (nesquena#6621)

The two _messageJumpScrollOwner guards (scrollIfPinned, scrollToBottom) are
pulled into other scroll test harnesses (test_issue6414, test_issue4856) that
stub the scroll env without declaring the new global; bare references threw
ReferenceError. Guard with typeof (matches the PR's own jumpScrollOwned check).
Also widen test_low_delta_wheel_intent_is_tracked_separately's source-slice
window 1400->1800 to still contain the (unchanged) _lastMessageWheelIntentMs
line after the +9-line wheel-up-during-jump block was inserted.
… tests (nesquena#6621 Fable finding ii)

Fable UX gate flagged that the PR's tests model only the stale load-time settle
callback, not the streaming case. Add two node-harness tests:
- a streaming render frame (scrollIfPinned) fired inside the jump-owner window
  must not snap the reader to the bottom (holds at target, 0 bottom-writes).
- a gentle wheel-up during the owner window after the programmatic latch stales
  hands ownership to the reader UNPINNED at their position, never pinned
  mid-transcript.
…quena#6621 Fable S3)

Fable's re-review (on the fixed code) confirmed the streaming blocker + wheel-up
concern resolved, and probe-proved one remaining narrow regression: a gentle
wheel-DOWN or touch scroll during the owner window cancelled the owner and
restored the pinned snapshot but did NOT re-unpin (the takeover was gated on
wheelUp only), so the next streaming token yanked the reader to the bottom.
Widen the takeover to any message-pane scroll input during the owner window.
Add a wheel-down regression test; widen the nesquena#4970 static-slice window to 2000
to still contain the (unchanged) _lastMessageWheelIntentMs line.
@nesquena-hermes
nesquena-hermes force-pushed the fix/jump-to-answer-scroll-settle branch from 0e528c0 to 3e9460c Compare August 14, 2026 04:12

@nesquena-hermes nesquena-hermes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-gated on the rebased head (3e9460c, rebased onto current master — the stale-base #6626-revert + CHANGELOG drift are gone; net diff is now static/ui.js +156 plus the scroll-settle tests only). Codex semantic-rebase pass: SAFE TO SHIP (jump ownership session-scoped, no currentSid/global conflict, all release/cancel transitions correct, 224 focused assertions). Full suite 14536 passed / 0 failed. This is on top of the prior 3 Codex rounds + Fable UX SHIP + maintainer screenshot verification. Superseding the earlier CHANGES_REQUESTED — all findings resolved. Shipping to the experimental channel.

@nesquena-hermes
nesquena-hermes merged commit 3a95d82 into nesquena:master Aug 14, 2026
23 checks passed
nesquena-hermes added a commit that referenced this pull request Aug 14, 2026
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Shipped in exp-v0.52.218, thanks @pxxD1998 🎉

The first-response jump now takes ownership of the scroll position and holds it where you put it, releasing only on an explicit downward scroll, End, or session switch — no more snap-back to the bottom while the answer streams in. Normal bottom-following (when you haven't jumped) is unchanged.

Gate: I rebased onto current master, then re-gated — Codex SAFE TO SHIP (jump ownership session-scoped, all release/cancel transitions correct, 224 focused assertions), full suite 14536/0. This was on top of the earlier deep-review rounds (Codex + Fable UX + maintainer screenshot verification across wide/desktop/tablet/mobile). Appreciate your patience through the iterations.

alai04 pushed a commit to alai04/hermes-webui that referenced this pull request Aug 31, 2026
)

* fix(chat): keep response jump position after session load

Cancel pending load-time bottom settling before an explicit response jump takes scroll ownership. Add a regression test covering the first-click race.

* fix: gate response jump ownership by destination

* fix: keep response jumps programmatic through smooth scroll

Native smooth-scroll frames from response jumps could reach the manual scroll listener and claim reader ownership before the final destination was known.

- own response-jump scrolling with a generation- and session-scoped lifecycle
- reconcile sticky ownership from final 80px tail geometry
- preserve current low-delta wheel takeover semantics when integrating with master
- cover visible assistant, user-row, and virtualized paths through the production scroll listener

* fix(scroll): hold reader off-bottom during jump owner + cancel jump on explicit End (nesquena#6621 gate fixes)

Codex gate found two defects in the jump-scroll ownership mechanism:
- Jump during an active stream snapped back to bottom on the next token: the
  owner suppressed the scroll listener but left the pre-jump pinned state, so
  scrollIfPinned() reclaimed the bottom. Now _beginMessageJumpScroll unpins for
  the ownership window and scrollIfPinned() no-ops while an owner exists.
- An explicit End click could be undone by the pending jump reconcile restoring
  the stale unpinned snapshot. scrollToBottom() now cancels the active jump
  owner first; _cancelMessageJumpScroll restores the preserved snapshot so a
  non-reconcile cancel doesn't leak the transient unpinned state.

* fix(scroll): drop undefined currentSid global ref in _messageJumpSessionId (nesquena#6621 brick-class scope-undef gate)

The PR's _messageJumpSessionId() referenced a bare 'currentSid' global that
does not exist in ui.js (everywhere else it's a local const from
S.session.session_id). test_static_js_scope_undef flagged it brick-class
(nesquena#3696). The S.session.session_id fallback already IS the canonical accessor;
removed the dead first line and updated the harness to drive the session-change
case via S.session.session_id.

* fix(scroll): round-2 gate fixes for jump-owner state transitions (nesquena#6621)

Re-gate found three more state-transition edge cases from the temp-unpin window:
- wheel-up interrupting an active jump owner after the programmatic latch
  expires now explicitly establishes the unpinned reader-owned state (was
  cancelling the jump but leaving pinned -> next token snapped to bottom).
- _resetStreamScrollFollow() now cancels the jump owner FIRST, before its
  pinned-state assignments, so a stream starting mid-jump can't have its pin
  undone by the snapshot restore -> auto-follow stays enabled.
- _finishMessageJumpScroll() flushes a deferred external-session refresh after
  reconciliation when the terminal state is pinned to the tail, so a refresh
  deferred during the temp-unpin window isn't stranded.

* fix(scroll): make jump-owner guards typeof-safe + widen static test window (nesquena#6621)

The two _messageJumpScrollOwner guards (scrollIfPinned, scrollToBottom) are
pulled into other scroll test harnesses (test_issue6414, test_issue4856) that
stub the scroll env without declaring the new global; bare references threw
ReferenceError. Guard with typeof (matches the PR's own jumpScrollOwned check).
Also widen test_low_delta_wheel_intent_is_tracked_separately's source-slice
window 1400->1800 to still contain the (unchanged) _lastMessageWheelIntentMs
line after the +9-line wheel-up-during-jump block was inserted.

* test(scroll): add streaming-frame-hold + wheel-during-jump regression tests (nesquena#6621 Fable finding ii)

Fable UX gate flagged that the PR's tests model only the stale load-time settle
callback, not the streaming case. Add two node-harness tests:
- a streaming render frame (scrollIfPinned) fired inside the jump-owner window
  must not snap the reader to the bottom (holds at target, 0 bottom-writes).
- a gentle wheel-up during the owner window after the programmatic latch stales
  hands ownership to the reader UNPINNED at their position, never pinned
  mid-transcript.

* fix(scroll): widen jump-owner takeover to downward + touch input (nesquena#6621 Fable S3)

Fable's re-review (on the fixed code) confirmed the streaming blocker + wheel-up
concern resolved, and probe-proved one remaining narrow regression: a gentle
wheel-DOWN or touch scroll during the owner window cancelled the owner and
restored the pinned snapshot but did NOT re-unpin (the takeover was gated on
wheelUp only), so the next streaming token yanked the reader to the bottom.
Widen the takeover to any message-pane scroll input during the owner window.
Add a wheel-down regression test; widen the nesquena#4970 static-slice window to 2000
to still contain the (unchanged) _lastMessageWheelIntentMs line.

---------

Co-authored-by: pxxD1998 <214340659+pxxD1998@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
alai04 pushed a commit to alai04/hermes-webui that referenced this pull request Aug 31, 2026
nesquena#7011)

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
rodrigogs pushed a commit to rodrigogs/hermes-webui that referenced this pull request Sep 26, 2026
)

* fix(chat): keep response jump position after session load

Cancel pending load-time bottom settling before an explicit response jump takes scroll ownership. Add a regression test covering the first-click race.

* fix: gate response jump ownership by destination

* fix: keep response jumps programmatic through smooth scroll

Native smooth-scroll frames from response jumps could reach the manual scroll listener and claim reader ownership before the final destination was known.

- own response-jump scrolling with a generation- and session-scoped lifecycle
- reconcile sticky ownership from final 80px tail geometry
- preserve current low-delta wheel takeover semantics when integrating with master
- cover visible assistant, user-row, and virtualized paths through the production scroll listener

* fix(scroll): hold reader off-bottom during jump owner + cancel jump on explicit End (nesquena#6621 gate fixes)

Codex gate found two defects in the jump-scroll ownership mechanism:
- Jump during an active stream snapped back to bottom on the next token: the
  owner suppressed the scroll listener but left the pre-jump pinned state, so
  scrollIfPinned() reclaimed the bottom. Now _beginMessageJumpScroll unpins for
  the ownership window and scrollIfPinned() no-ops while an owner exists.
- An explicit End click could be undone by the pending jump reconcile restoring
  the stale unpinned snapshot. scrollToBottom() now cancels the active jump
  owner first; _cancelMessageJumpScroll restores the preserved snapshot so a
  non-reconcile cancel doesn't leak the transient unpinned state.

* fix(scroll): drop undefined currentSid global ref in _messageJumpSessionId (nesquena#6621 brick-class scope-undef gate)

The PR's _messageJumpSessionId() referenced a bare 'currentSid' global that
does not exist in ui.js (everywhere else it's a local const from
S.session.session_id). test_static_js_scope_undef flagged it brick-class
(nesquena#3696). The S.session.session_id fallback already IS the canonical accessor;
removed the dead first line and updated the harness to drive the session-change
case via S.session.session_id.

* fix(scroll): round-2 gate fixes for jump-owner state transitions (nesquena#6621)

Re-gate found three more state-transition edge cases from the temp-unpin window:
- wheel-up interrupting an active jump owner after the programmatic latch
  expires now explicitly establishes the unpinned reader-owned state (was
  cancelling the jump but leaving pinned -> next token snapped to bottom).
- _resetStreamScrollFollow() now cancels the jump owner FIRST, before its
  pinned-state assignments, so a stream starting mid-jump can't have its pin
  undone by the snapshot restore -> auto-follow stays enabled.
- _finishMessageJumpScroll() flushes a deferred external-session refresh after
  reconciliation when the terminal state is pinned to the tail, so a refresh
  deferred during the temp-unpin window isn't stranded.

* fix(scroll): make jump-owner guards typeof-safe + widen static test window (nesquena#6621)

The two _messageJumpScrollOwner guards (scrollIfPinned, scrollToBottom) are
pulled into other scroll test harnesses (test_issue6414, test_issue4856) that
stub the scroll env without declaring the new global; bare references threw
ReferenceError. Guard with typeof (matches the PR's own jumpScrollOwned check).
Also widen test_low_delta_wheel_intent_is_tracked_separately's source-slice
window 1400->1800 to still contain the (unchanged) _lastMessageWheelIntentMs
line after the +9-line wheel-up-during-jump block was inserted.

* test(scroll): add streaming-frame-hold + wheel-during-jump regression tests (nesquena#6621 Fable finding ii)

Fable UX gate flagged that the PR's tests model only the stale load-time settle
callback, not the streaming case. Add two node-harness tests:
- a streaming render frame (scrollIfPinned) fired inside the jump-owner window
  must not snap the reader to the bottom (holds at target, 0 bottom-writes).
- a gentle wheel-up during the owner window after the programmatic latch stales
  hands ownership to the reader UNPINNED at their position, never pinned
  mid-transcript.

* fix(scroll): widen jump-owner takeover to downward + touch input (nesquena#6621 Fable S3)

Fable's re-review (on the fixed code) confirmed the streaming blocker + wheel-up
concern resolved, and probe-proved one remaining narrow regression: a gentle
wheel-DOWN or touch scroll during the owner window cancelled the owner and
restored the pinned snapshot but did NOT re-unpin (the takeover was gated on
wheelUp only), so the next streaming token yanked the reader to the bottom.
Widen the takeover to any message-pane scroll input during the owner window.
Add a wheel-down regression test; widen the nesquena#4970 static-slice window to 2000
to still contain the (unchanged) _lastMessageWheelIntentMs line.

---------

Co-authored-by: pxxD1998 <214340659+pxxD1998@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
rodrigogs pushed a commit to rodrigogs/hermes-webui that referenced this pull request Sep 26, 2026
nesquena#7011)

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gate-fail Gate found blocking issue(s); fix-spec in comment; awaiting fix/re-push size:L Large PR (>10 files or >250 LOC)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants