fix(webui): reserve real user-row height in non-virtualized transcripts to stop scroll jump-back - #5751
fix(webui): reserve real user-row height in non-virtualized transcripts to stop scroll jump-back#5751allenliang2022 wants to merge 1 commit into
Conversation
|
| Filename | Overview |
|---|---|
| static/ui.js | Updates transcript rendering helpers to preserve user-row intrinsic heights across non-virtualized rebuilds. |
| tests/test_issue5744_nonvirtual_userrow_collapse_jumpback.py | Adds regression coverage for CJK estimates, max-based reservation, pre-wipe ordering, viewport filtering, and estimate flooring. |
Reviews (2): Last reviewed commit: "fix(webui): reserve real user-row height..." | Re-trigger Greptile
|
Thanks for this — the non-virtualized row-height reserve is the right analog to #5638's virtualized measure pass, and reading the pre-wipe rects (before One blocker before this can go green, and it's a trivial one: CI red — The failure is not a real behavioral regression — your fix_idx = UI_JS.find("window._fixMobileScrollJank()")
wipe_idx = UI_JS.find("innerHTML=''", fix_idx) # first literal occurrence
window = UI_JS[fix_idx:wipe_idx]
assert "_programmaticScroll=true" in windowYour new explanatory comment at // Pre-wipe capture: read the still-laid-out user rows' REAL heights before innerHTML=''So Fix (pick either):
I'd lean option 1 — the test is a pre-existing shipped guard and the comment is the only thing tripping it. Also heads-up: this touches the crown-jewel chat scroll surface, so once CI is green it'll go through the visible-UI review gate (screen-recording proof of the jump-back fix across desktop + mobile) before it ships — I'll handle that side. Just get the comment fixed so CI clears. |
…ts to stop scroll jump-back When transcript virtualization is disabled (the nesquena#4325 opt-out, _virtualizeTranscript===false), renderMessages() renders every row with no windowing and never runs the virtualized measure pass (_updateMessageVirtualMeasurements early-returns when !virtualized). Under @media (pointer: coarse), .msg-row[data-role="user"] carries content-visibility: auto; contain-intrinsic-size: auto 96px. Every rebuild does inner.innerHTML='' then recreates rows as fresh elements, so a fresh off-screen tall user row (a long paste measuring thousands of px) reserves only the flat estimate instead of its real height. scrollHeight shrinks by (realHeight - estimate), the browser force-clamps scrollTop, and the viewport jumps backward (a browser clamp, JS=none, so scrollTop-write compensation cannot catch it). nesquena#5638 fixed this for the virtualized wipe-and-rebuild path but left the non-virtualized full-rebuild path uncovered. Fix, three coordinated parts: - _estimateUserRowIntrinsicHeight weights CJK / full-width characters as ~2 columns (they wrap at ~24 chars/line, not 48), so a CJK paste reserves close to its real height even before it is ever measured. - _applyUserRowIntrinsicHeight reserves max(remembered, estimate): a remembered height can be a partial paint (a row taller than the viewport only paints its intersecting slice under content-visibility:auto), so the estimate floors it. - _rememberRenderedUserRowIntrinsicHeights, called pre-wipe inside renderMessages, reads the still-laid-out rows' real heights and persists them keyed by session-relative index, only for rows within the viewport (a fully off-screen never-painted row reports its collapsed reserve and must not poison the map), floored at the estimate. Desktop rests at content-visibility:visible so intrinsic-size is inert there; verified no behavior change with pointer:fine. Adds tests/test_issue5744_nonvirtual_userrow_collapse_jumpback.py (7 mutation-checked node-harness tests). Existing nesquena#5637/nesquena#5638 suites and the render/virtualization suites pass.
8a788fe to
b6319c7
Compare
|
Thanks for the precise diagnosis — you're exactly right, my explanatory comment carried the literal Took option 1: reworded the comment to "…read the still-laid-out user rows' REAL heights before the wipe below destroys them" so it no longer contains the token. No behavior change — the Pushed as an amend (
Understood on the visible-UI review gate — I'll leave the screen-recording proof to you as you offered. Let me know if anything else needs adjusting. |
|
Confirmed — the reword fixes the guard-test collision cleanly, and CI is now green across all 15 shards + lint + browser-smoke. What was actually brokenThe fix_idx = UI_JS.find("window._fixMobileScrollJank()")
wipe_idx = UI_JS.find("innerHTML=''", fix_idx)
window = UI_JS[fix_idx:wipe_idx]
assert "_programmaticScroll=true" in windowYour original pre-wipe capture comment carried the literal Why the reworded head is correctAt the PR head the ordering in
Now the first On the behavioral coreRe-reading the diff, the two-part reserve logic is sound. This is the correct non-virtualized analog of #5638's virtualized measure pass. LGTM from my read; deferring the final merge call to a maintainer. |
🔬 Gate certification — GREEN ✅ (rebased onto current master v0.51.922)Full authoritative gate (Codex + Fable + full suite + my own checks), on a worktree with current master merged in — because this PR was 8 commits behind and #5742 (desktop scroll compensation) shipped in v0.51.922 since your last push, the key question was whether the two scroll fixes interact. They don't. Clean merge, disjoint Codex — SAFE TO SHIP. Verified: Fable UX — SHIP-UX, with a full platform-matrix proof of the #5742 interaction:
Full suite: 12236 passed / 0 failed. Scroll cluster: #4856 + #5637 + your new #5744 tests = 51 passed on the merged base. This is the correct non-virtualized analog of #5638's virtualized measure pass, on a distinct uncovered code path. Nicely scoped, mutation-checked tests, real coverage. Queuing for the maintainer's ship nod (visible scroll surface — the only reason it's not auto-merged is the standing visible-UI sign-off rule; the motion repro needs a long coarse-pointer transcript so it's a trust-the-gate call, same as #5742). |
🔬 Gate certification — GREEN ✅ (full gate: Codex + Fable-UX + suite) · ⏸️ visible scroll → Nathan sign-off · #5637/#5638 familyCertified head: What I ran (rebased worktree
|
| Gate | Result |
|---|---|
| Rebase onto current master | ✅ git apply clean |
| Codex (max-reserve / over-reserve / CJK / non-virt-only / composes) | SAFE TO SHIP — 0 findings |
| Fable-UX | SHIP-UX — no jump-back, no whitespace gap |
| Full pytest suite | ✅ 12323 passed, 0 failed (+ 572 scroll/anchor/pin tests) |
Findings
✅ Clean fix: the non-virtualized transcript path (_virtualizeTranscript===false, the #4325 opt-out) never ran the virtualized measure pass, so a user row's real height was never remembered → scrollHeight collapsed on rebuild → jump-back. The fix reserves max(remembered-measurement, content-estimate): _estimateUserRowIntrinsicHeight is CJK-aware (wide chars weighted as 2 columns), and the max protects against partial-paint under-reads (a row taller than the viewport that only partially painted via content-visibility:auto reports a short height — the estimate floors it). Codex confirmed: no under-reserve on partial paint, no over-reserve whitespace/scroll-past-end, CJK weighting sound, non-virtualized-only (no double-apply with the virtualized #5638 pass), content-visibility interaction correct, composes with the whole #5637 family (#5635/#5638/#5666/#5672/#5681/#5685/#5742). Fable confirmed no jump-back on rebuild, no whitespace gap, pinned reader still follows. Full suite green (572 scroll tests).
Recommendation to Nathan
GREEN — merge from branch gate-rebase/5751-nonvirt-userrow-height (sha:aee593d2) — after a screen-recording glance (non-virtualized transcript: scroll up into history during a live rebuild → reader stays put, no jump-back, no whitespace gap under a tall user row). Completes the #5637/#5638 scroll-stability family by covering the non-virtualized (#4325 opt-out) path. Codex SAFE + Fable SHIP-UX + full suite green. concept 4/5. Author @allenliang2022 (T1). crit=3, scroll.
_Gate-certifier layer (warm-up → gate → release). Non-virtualized transcript (virtualizeTranscript===false, #4325 opt-out) never remembered user-row height → scrollHeight collapse → jump-back. Fix reserves max(remembered, CJK-aware estimate) — max floors partial-paint under-reads (content-visibility:auto short-reports), estimate doesn't over-reserve. Codex SAFE (no under/over-reserve, CJK sound, non-virt-only, composes #5637 family) + Fable-UX SHIP-UX (no jump-back, no whitespace gap) + full suite green (0 failed, 12323) + 572 scroll tests. Visible scroll → Nathan. Cert valid for sha:aee593d2.
Release: non-virtualized transcript scroll jump-back (#5751)
|
Shipped in v0.51.923 — thanks @allenliang2022. 🎉 This completes the scroll jump-back cluster: #5637/#5638 (virtualized) + #5742 (desktop) + now #5751 (non-virtualized / coarse-pointer). With virtualization off, a fresh off-screen tall user row under Gate (rebased onto current master v0.51.923, since #5742 shipped since your last push):
The re-worded comment fixed the #4856 guard-test collision exactly as discussed. Nicely scoped, mutation-checked tests, correct non-virtualized analog of #5638's measure pass. Deployed and verified live. (CI needed a couple of re-runs for an unrelated Playwright-browser-install runner flake — not your code.) |
Summary
Fixes a chat scroll jump-back (viewport snaps hundreds/thousands of px toward the top) that fires on touch / coarse-pointer devices while a response is streaming and the reader is scrolled up in history. It is a follow-up to #5637 / #5638: those fixed the collapse for the virtualized wipe-and-rebuild path, but the non-virtualized transcript path (the #4325 opt-out,
_virtualizeTranscript === false) was left uncovered.Root cause
When transcript virtualization is disabled,
renderMessages()renders every row with no windowing and never runs the virtualized measure pass —_updateMessageVirtualMeasurementsearly-returns when!virtualWindow.virtualized, and that pass is what remembers a user row's real height.Under
@media (pointer: coarse),.msg-row[data-role="user"]carriescontent-visibility: auto; contain-intrinsic-size: auto 96px. EveryrenderMessages()rebuild doesinner.innerHTML=''then recreates all rows as fresh elements. A fresh, off-screen tall user row (e.g. a long paste measuring thousands of px) has never painted at full size, so it reserves only the flatcontain-intrinsic-sizeestimate instead of its real height.scrollHeightshrinks by(realHeight − estimate), the browser force-clampsscrollTopto keep it within range, and the viewport jumps backward.This is a browser clamp, not a JS
scrollTopwrite, so scroll-anchor / stale-snapshot compensation paths cannot catch it — the row genuinely has no height that frame.Desktop rests at
content-visibility: visible(intrinsic-size is inert), which is why the mouse/desktop path never reproduces it.Fix (three coordinated parts, all in
static/ui.js)_estimateUserRowIntrinsicHeightnow weights full-width / CJK characters as ~2 columns (they wrap at ~24 chars/line, not 48). A long Chinese/Japanese/Korean paste previously under-estimated by ~2x; now a fresh never-measured row reserves close to its real height. This is the only backstop for a row the reader has never scrolled into view (a never-paintedcontent-visibility:autorow reports only its reserve, so there is nothing to measure — the estimate must carry it).max(remembered, estimate)in_applyUserRowIntrinsicHeight— a remembered height can be a partial paint: a row taller than the viewport only ever paints its intersecting slice undercontent-visibility:auto, so its measured height is a fraction of the real row. Reserving the larger of the remembered value and the content estimate prevents a partial measurement from under-reserving. A full measurement (short row, fully painted) still wins when it exceeds the estimate, preserving the fix(webui): stop mobile scroll jump-back — scope content-visibility + hold unpinned position (#5637) #5638 behavior._rememberRenderedUserRowIntrinsicHeights()runs just beforeinner.innerHTML=''insiderenderMessages(the non-virtualized analog of fix(webui): stop mobile scroll jump-back — scope content-visibility + hold unpinned position (#5637) #5638's virtualized measure pass). It reads the still-laid-out old rows' real heights — reliable because those elements have painted — and persists them keyed by session-relative index. It only trusts rows currently within the viewport band (a fully off-screen never-painted row reports its collapsed reserve and must not poison the remembered map) and floors every persisted value at the content estimate.Verification
Reproduced in a mobile-emulated (coarse-pointer) browser against a long real-world transcript with a tall user row and virtualization disabled. Measured the same rebuild-while-scrolled operation before and after:
pointer: fine(content-visibility inert)Tests
Adds
tests/test_issue5744_nonvirtual_userrow_collapse_jumpback.py— 7 node-harness tests, each mutation-checked (reverting the CJK weighting, the in-viewport guard, or the pre-wipe ordering each makes the corresponding test fail). The existing #5637 / #5638 suites and the render / virtualization suites all pass.Notes for the maintainer
I could not attach a screen recording — the only long transcripts that reproduce this are private conversations, so the evidence here is the mutation-checked harness plus the before/after measurement table above. Happy to add any additional automated coverage you'd like.
This targets the same jump-back class as #5637 / #5638 but a distinct, uncovered code path (non-virtualized full rebuild vs. virtualized windowed rebuild), so it is a separate one-logical-change PR.