Scrolling breaks mid-gesture on long sessions with transcript virtualization ON: viewport teleports 31-41Kpx, oscillates windowed↔full-render, fights input direction (measured repro)

Author: CharlesMcquadeCreated Sep 16, 2026Updated Sep 17, 2026
Labelsbuguxsprint-candidateperformance

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,120pxscrollHeight 57,111
    • full-render state: pad collapsed, all 176 rows mounted → scrollHeight 15,987
  • Every flip teleports scrollTop by 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 scrollTop by 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)

  1. On desktop, .messages has overflow-anchor:none (style.css:2274, hover/fine media block) — nothing native holds the viewport; position-holding is entirely the JS compensation path (_compensateScrollForMeasurementDelta).
  2. Unmeasured rows use flat per-role height constants (rowHeightFor in _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.
  3. During an upward gesture, the scroll listener's windowed re-render (_scheduleMessageVirtualizedRenderrenderMessages({preserveScroll:true})) rewrites scrollTop against a scrollHeight that 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.
  4. With endless scroll enabled, _loadOlderMessages grows the window mid-gesture (78→168→176 rows), which compounds the re-windowing during the gesture.
  5. 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
javascript
// 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.