Scrolling breaks mid-gesture on long sessions with transcript virtualization ON: viewport teleports 31-41Kpx, oscillates windowed↔full-render, fights input direction (measured repro)
User story
I was reading back through a longish session (the region had big tool outputs, markdown tables, and an inline screenshot image). Scrolling up through the history, the viewport suddenly stops behaving — it teleports, and scrolling effectively "gets stuck": I can no longer scroll up or down, the page feels like it's fighting my input. Flipping Settings → Virtualize long transcripts off made it disappear completely.
Reproduced on master with measurements
Automated repro on pristine master (94fd2da8), desktop Chrome, transcript virtualization explicitly opted in (virtualize_transcript: true + virtualize_transcript_optin: true, the documented opt-in path) and session_endless_scroll: true. Test session: 345 messages (176 renderable), tall tool-output rows, markdown tables, one 75KB inline base64 image.
I drove real CDP mouse-wheel input upward from the live tail at ~35 events/sec (continuous, like a trackpad flick) and sampled scrollTop/scrollHeight/render-window state ~7×/sec:
Virtualization ON — the viewport oscillates violently instead of scrolling:
- The pane ping-pongs between two states, 29 times in 90 seconds:
- windowed state: 59 rows rendered + topPad spacer 34,120px →
scrollHeight57,111 - full-render state: pad collapsed, all 176 rows mounted →
scrollHeight15,987
- windowed state: 59 rows rendered + topPad spacer 34,120px →
- Every flip teleports
scrollTopby 31,000–41,000px, repeatedly against the input direction while scrolling up - Between teleports, the pane drifts +300–1,600px against the wheel — an active JS writer is fighting the user's input
- The estimated topPad (~34K px) stands in for content whose real height is ~11K px: the estimate is ~3× too tall, so every re-window flings
scrollTopby the error
Virtualization OFF (same session, same input script, runtime window._virtualizeTranscript=false): one clean glide from bottom to top in 6.6s. scrollHeight stable at 15,901 the whole way. Zero teleports, zero against-input movement.
Mechanism (as far as I can pin it)
- On desktop,
.messageshasoverflow-anchor:none(style.css:2274, hover/fine media block) — nothing native holds the viewport; position-holding is entirely the JS compensation path (_compensateScrollForMeasurementDelta). - Unmeasured rows use flat per-role height constants (
rowHeightForin_messageVirtualWindow). For this transcript the estimated geometry (57K px) is 3.6× the real height (15.9K px). PR #7283 independently measured the same estimator ~34% short on a 2,000-message session — same estimator, different direction of wrong, both large enough to fling the viewport. - During an upward gesture, the scroll listener's windowed re-render (
_scheduleMessageVirtualizedRender→renderMessages({preserveScroll:true})) rewritesscrollTopagainst ascrollHeightthat swings by ~41K px. When the anchor row is recycled out of the window, compensation falls back to the topPad delta — which is itself estimate-based. - With endless scroll enabled,
_loadOlderMessagesgrows the window mid-gesture (78→168→176 rows), which compounds the re-windowing during the gesture. - The two writers then feed each other: window flip → pad-based fling → viewport lands near the tail → window re-computes → pad returns → fling again. That loop is the oscillation, and at human speed it presents as "scrolling is stuck / the page fights me."
Also relevant: the retained #4343 migration comment (api/config.py, "caused scroll-up flicker on long sessions with tall tool-call rows (variable-height anchor oscillation)") — this looks like the same class, not fully solved; #6799 fixed the idle re-render loop, but the gesture-time oscillation reproduces on today's master.
Suggested directions (not prescriptive)
- Defer window re-computation while scroll input is fresh (the session-list already has an interaction-guard pattern for exactly this); extend the window atomically at its edge instead of re-deriving mid-gesture
- Calibrate heights from measurements (#7283 direction) so pads track reality within a few percent
- Consider anchoring to message identity (sessionIdx) at gesture time rather than estimated pads
Repro harness (essentials)
CDP wheel-drive + sampler used for the measurements above// sampled probe (evaluated in-page)
(() => {
const el = document.getElementById('messages');
const before = el.querySelector('[data-virtual-spacer="before"]');
const rows = [...el.querySelectorAll('[data-msg-idx]')];
return {
top: Math.round(el.scrollTop), H: el.scrollHeight,
pad: before ? Math.round(parseFloat(before.style.height)) : null,
rows: rows.length,
first: rows[0]?.dataset.msgIdx, last: rows.at(-1)?.dataset.msgIdx,
};
})()Wheel input via Input.dispatchMouseEvent {type:'mouseWheel', deltaY:-120} at ~35/sec from scrollTop = scrollHeight; flag |ΔscrollTop| > 3000px between samples as a teleport, sign vs. input direction recorded.
Not verified / honest caveats
- A permanent hard wedge (scroll fully unresponsive until reload) was not reproduced synthetically — but the teleport/oscillation was, deterministically (29× in 90s), and at human trackpad speeds the fling lands the reader at boundaries where input appears dead.
- Synthetic CDP wheel input only; not tested on touch devices.
- Did not bisect the exact introducing commit; reproduced on current master.
Source: nesquena/hermes-webui