fix(ui): stabilize long-session scroll geometry for content-visibility turns - #828
Merged
Conversation
9 tasks
Astro-Han
force-pushed
the
fix/ui-chat-scroll-warmup
branch
from
July 12, 2026 14:25
6a8180b to
6339580
Compare
3 tasks
Never-rendered turns in a remounted long session occupy only the 250px contain-intrinsic-size placeholder, so scrolling up inflates the document turn by turn while scroll anchoring keeps compensating — the top keeps receding. Verified against the live app: a seeded 24-turn session mounted at scrollHeight 14640 vs 31872 real and bounced through 51 anchor-corrected steps to reach the top; Chromium never renders the skipped turns on its own. Add a chunked, bottom-up idle-time warm-up (createTurnSizeWarmup) that forces each turn through one rendering opportunity so the browser records its last remembered size, then releases it back to content-visibility:auto. After warm-up the same session reports exact geometry after ~2 idle chunks and scrolls to the top with constant height and monotonic scrollTop.
Opening a long session pins the viewport against placeholder geometry (content-visibility turns still at their 250px estimate). The turns then inflate to real heights with no DOM mutation and no scroll event, so the one-shot pin plus the mutation-only follower left the viewport stranded mid-document — verified live at scrollTop 5408 of an eventual 31872. Give createPinnedBottomFollower a second channel: a ResizeObserver on the content's element children (re-synced when mutations add turns), so any height growth re-pins while the user is pinned. Verified live: a remounted 24-turn session mounts at distanceFromBottom 0, stays 0 while warm-up grows the document 23256 to 31872, and an immediate user scroll-up is respected (distance preserved exactly, no yank-back).
The scroll-geometry E2E contract caught the warm-up remembering sizes from a transient mount-time layout: until the lazy markdown-body chunk commits, every bubble is the plain-text Suspense fallback (~7.5px taller per turn), so each turn scrolled into view corrected its height and the scrollbar drifted through the whole climb (24 x 7.5px). Awaiting the chunk import is not enough — the Suspense retry is a low-priority render that can commit after an idle callback — so the walk now starts when the DOM holds no .maka-markdown-pending fallback (bounded poll, degrades to warming anyway) and document.fonts.ready has resolved. Warm-up also now forces 'contain: layout style paint' alongside content-visibility: visible, matching the containment content-visibility: auto applies to on-screen turns, so sizes are measured under the same containment they will have when the turn scrolls into view.
Add a visual-smoke long-transcript scenario (24 turns, ~1300px each, opened as the active session on boot) so E2E can mount a session whose above-viewport turns are render-skipped placeholders — previously impossible outside fixture mode because sessions:readMessages projects from the AgentRun/RuntimeEvent ledger, so store-seeded messages read back empty, and building tall turns through the fake backend streams for minutes. The scroll-geometry spec locks both user-visible invariants: the session opens pinned to bottom and stays pinned while the warm-up grows the document, and scrolling a settled session to the top observes a single scrollHeight in the honest number of steps. Probes read only scroller metrics; per-turn getBoundingClientRect would force-render skipped turns and mask the regression. Verified RED on a build with both fixes reverted (mid-document mount; 23 distinct heights while climbing).
Two consecutive scrollHeight reads agreeing is not proof the warm-up finished: on a slow machine the fonts/lazy-markdown gates can delay the walk past the first reads, and two agreements on the PLACEHOLDER height would declare the geometry settled before it ever grew. Require the fixture's warmed scale (24 turns x >800px real vs 250px placeholder) before the stability check, and extract the settle/climb helpers.
Leaving chat for skills/automations/daily-review unmounts the scroll DOM; returning rebuilds every .maka-turn with no remembered size, so the placeholder geometry (and the endless upward scroll) came back. The pinned-bottom follower effect already re-runs on props.mode for exactly this reason — the warm-up effect missed the dependency. Found by external review (P1). RED-verified: the new E2E test fails without the dependency (settle times out at placeholder height).
If the lazy markdown chunk commits after the 5s cap, the walk records the plain-text fallback layout and re-creates the exact per-arrival drift the warm-up exists to remove. Poll until the pending markers are gone instead: warming a transient layout is never right, and if the chunk truly never loads, placeholder scroll geometry is the least of that session's problems. Found by external review (P2).
A stalled compositor freezes mid-walk geometry, and frozen reads are equal reads — on CI the settle check declared a stuck warm-up "settled" and the pinned test passed vacuously. The walk's forced inline content-visibility is visible DOM state: any remaining forced turn means the walk is still in flight, so require its absence before the stability check.
Both climb tests died on CI as bare 60s timeouts with the evaluate still in flight — zero information twice in a row. Give the climb an in-page deadline (30s, under the test timeout), a 2s watchdog per frame wait, and return frame statistics (frames seen, max frame gap, elapsed); assert on them so a stalled or crawling compositor fails with numbers instead of 'Target page ... closed'.
E2E windows launch hidden so local runs never steal focus. On Linux CI a hidden window's compositor only produces ~1fps BeginFrames — measured by the instrumented climb: 38 frames over 31s, single stable scrollHeight (the geometry itself was already correct). The warm-up's handful of frames squeaked through; the climb's ~106 frames cannot. Under xvfb there is no focus to steal, so the E2E harness sets MAKA_E2E_SHOW_WINDOW=1 when CI is set and main.ts lets it override startHidden. Local runs and visual-smoke capture stay hidden. Replaces the backgroundThrottling attempt, which the instrumented run refuted (the throttle applies to BeginFrame production for unmapped windows, not renderer-side timer policy).
#831 lifted section routing out of ChatView: leaving chat now unmounts the component together with its scroll DOM, and the mode prop is gone. The warm-up effect no longer needs (or can have) a props.mode dependency — returning to a session re-runs it naturally against the rebuilt turn nodes. The E2E mode-switch regression test still locks the user-visible invariant end-to-end.
Astro-Han
force-pushed
the
fix/ui-chat-scroll-warmup
branch
from
July 12, 2026 14:38
6339580 to
8d65a83
Compare
This was referenced Jul 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
content-visibility: autoturns in idle-time chunks so each turn's real height replaces the 250pxcontain-intrinsic-sizeestimate — upward scrolling stops "receding".long-transcriptvisual-smoke fixture and an E2E scroll-geometry contract.Why
Closes #827. Long sessions felt endless when scrolling up and could open mid-document: the un-warmed transcript underestimates true height ~2x (14640 vs 31872 on the fixture-scale session), placeholders inflate per viewport arrival, and scroll anchoring keeps repositioning. Root cause and measurements in #827.
Verification
@maka/ui111 pass (newturn-size-warmup+ pinned-bottom size-channel tests); full workspace suite 2346 pass; typecheck clean.MAKA_E2E_SHOW_WINDOW, CI-only); the climb is now hang-proof and reports frame statistics on failure; the settle check refuses an in-flight warm-up walk.Impact
User-visible fix only — long sessions open pinned to bottom and scroll honestly. No API, storage, or migration impact. Adds the test-only
long-transcriptvisual-smoke scenario. Release note: one fix line.Reviewer notes
contain: layout style painttogether withcontent-visibility: visible:autokeeps containment even while on screen, so remembered sizes must be measured under the same containment or every later arrival re-corrects the height.props.modedep).Ready for review
Verificationexplains why it is not applicable