Skip to content

fix(ui): stabilize long-session scroll geometry for content-visibility turns - #828

Merged
Astro-Han merged 11 commits into
mainfrom
fix/ui-chat-scroll-warmup
Jul 12, 2026
Merged

fix(ui): stabilize long-session scroll geometry for content-visibility turns#828
Astro-Han merged 11 commits into
mainfrom
fix/ui-chat-scroll-warmup

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Warm up content-visibility: auto turns in idle-time chunks so each turn's real height replaces the 250px contain-intrinsic-size estimate — upward scrolling stops "receding".
  • Add a ResizeObserver channel to the pinned-bottom follower so placeholder inflation (no mutation, no scroll event) can't strand a freshly opened long session mid-document.
  • Record warm-up sizes only from the final layout (fonts ready, lazy markdown committed, same containment as later viewport arrivals), and re-walk when a mode switch rebuilds the transcript DOM.
  • Lock both user-visible invariants with a deterministic long-transcript visual-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

  • Unit (node:test): @maka/ui 111 pass (new turn-size-warmup + pinned-bottom size-channel tests); full workspace suite 2346 pass; typecheck clean.
  • E2E (Playwright + Electron): 8/8 including 3 scroll-geometry tests; each RED-verified — reverting the corresponding fix makes exactly that test fail.
  • Live A/B in the running app: mount scrollHeight 14640 → settles at 31872 with distanceFromBottom pinned at 0 throughout; settled scroll-up climb sees a single scrollHeight and the honest step count; a user scroll position (1848) is preserved untouched during warm-up.
  • External review (Codex), two rounds: round-1 findings (P1 mode-switch re-warm, P2 warm-anyway deadline, P2 E2E settle gate) fixed and locked; round 2 PASS with no open P0–P3.
  • CI E2E initially failed: hidden windows only get ~1fps compositor frames on Linux, hanging the frame-paced climb. Fixed at the root — E2E windows are shown under xvfb (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.
  • No before/after video: the defect is scroll-anchoring drift, which static screenshots can't show; the numeric E2E contract (honest step count, single scrollHeight) is the substitute evidence.

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-transcript visual-smoke scenario. Release note: one fix line.

Reviewer notes

  • The warm-up forces contain: layout style paint together with content-visibility: visible: auto keeps containment even while on screen, so remembered sizes must be measured under the same containment or every later arrival re-corrects the height.
  • Structural root cause of the review P1 (ChatView owning non-chat mode dispatch) is tracked separately in refactor(ui): restore ChatView boundaries in three stages #829; this PR takes the minimal in-place fix (props.mode dep).
  • Consciously rejected (review P3): unobserving removed children in the follower's ResizeObserver — removals are rare and inert, and both observers disconnect on session/mode cleanup.

Ready for review

  • Verification is complete and the results above are current
  • Impact, compatibility, migration, documentation, and release-note needs are addressed
  • User-visible UI/UX changes include visual evidence, or Verification explains why it is not applicable

Astro-Han added 11 commits July 12, 2026 22:34
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
Astro-Han force-pushed the fix/ui-chat-scroll-warmup branch from 6339580 to 8d65a83 Compare July 12, 2026 14:38
@Astro-Han
Astro-Han merged commit 8a47301 into main Jul 12, 2026
3 checks passed
@Astro-Han
Astro-Han deleted the fix/ui-chat-scroll-warmup branch July 12, 2026 14:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(ui): long-session scroll geometry — endless upward scroll, and remount landing mid-document

1 participant