Skip to content

perf(ui): mount a switched-to transcript progressively - #2191

Merged
Astro-Han merged 5 commits into
apache:mainfrom
Benjamin-eecs:fix/2052-progressive-transcript-mount
Aug 5, 2026
Merged

perf(ui): mount a switched-to transcript progressively#2191
Astro-Han merged 5 commits into
apache:mainfrom
Benjamin-eecs:fix/2052-progressive-transcript-mount

Conversation

@Benjamin-eecs

@Benjamin-eecs Benjamin-eecs commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Switching back to a long session froze the renderer for ~0.35s. The whole transcript mounted in one React commit, and 22k nodes in one commit cost 147ms render, 57ms restyle, and 144ms layout in the issue's trace. The issue already ruled out the spring scroll and the warm-up, so this reduces what mounts.

  • The first commit after a switch mounts only the last 10 turns. The rest arrives in idle chunks of 4; each chunk rewrites scrollTop in the same frame as its commit, pinned inside Astryx's 10px bottom lock, reading position preserved anywhere else.
  • The window logic is pure (progressive-turn-mount.ts) and reconciled during render, so the small commit is the first commit. A bulk arrival without a session change (message loading, revision navigation) re-windows the same way; streaming appends land in the mounted tail and never touch the window.
  • The full turns array still feeds deriveTurnPresentation and the prompt rail. Presentation caching and rail geometry are not window-dependent; only the JSX mapping is sliced.
  • The turn-size warm-up starts once the fill completes, so its one-shot NodeList snapshot sees every turn. A rail click on a not-yet-mounted turn mounts it and then scrolls (new onNavigateFallback). A search target is mounted before use-chat-scroll queries it.

Closes #2052

Verification

Measured with the issue's own scenario on the e2e long-transcript fixture (24 markdown-heavy turns): a Playwright probe creates an empty session, switches back to the long one, and records PerformanceObserver longtask entries, 5 rounds per configuration, 6x CDP CPU throttling for a stable contrast (unthrottled, this machine produces no >50ms tasks in either configuration).

The first long task after the click is the switch-back commit, the one that blocks interaction:

first task after click, 5 rounds
main 134 / 169 / 124 / 146 / 120 ms
this branch 64 / 53 / 54 / 50 / 58 ms

Both configurations then show one ~130-160ms task at ~1.1s after the click; that is the pre-existing turn-size warm-up batch, which runs at idle and is unchanged here.

  • New pure tests for the window arithmetic and scroll compensation: 13 pass. New SSR tests prove the first commit is windowed (a 30-turn transcript renders exactly the last 10 turn sections while the prompt rail keeps all 30 ticks); these caught a real window-seeding bug during development.
  • Full @maka/ui suite: 339 tests, 337 passed; the 2 failures are in astryx-form-controls-localization and reproduce identically with clean main sources in this environment.
  • Desktop ui-render-memo-boundary-contract (renders ChatView from dist and pins the memo-skip contract): 14/14.

Switching back to a long session rendered every turn in one React
commit, and inserting ~22k nodes at once is a single ~0.35s frame
(the apache#2052 trace: 147ms render, 57ms restyle, 144ms layout). The first
commit now mounts only a tail window of turns; the rest arrive in idle
chunks with scroll compensation, staying pinned inside the bottom lock
and preserving the reading position anywhere else.

The full turns array still feeds deriveTurnPresentation and the prompt
rail, so presentation caching and rail geometry are unchanged. The
turn-size warm-up starts once the fill completes, a rail click on a
not-yet-mounted turn mounts it and then scrolls, and a search target
is mounted before use-chat-scroll queries it.

Closes apache#2052
@Benjamin-eecs
Benjamin-eecs marked this pull request as ready for review August 4, 2026 19:27
Copilot AI lite review requested due to automatic review settings August 4, 2026 19:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Benjamin-eecs
Benjamin-eecs marked this pull request as draft August 4, 2026 19:35
@Benjamin-eecs
Benjamin-eecs marked this pull request as ready for review August 4, 2026 19:48
…transcript-mount

# Conflicts:
#	packages/ui/src/chat-view.tsx
@Astro-Han

Copy link
Copy Markdown
Contributor

Thanks for the careful attribution and performance work here. The measured improvement is valuable, and I think progressive mounting is a reasonable incremental step for this issue; this PR does not need to grow into full transcript virtualization or an Astryx-level change.

A fresh review found three scoped items I would like to close before approval:

  1. The prompt rail’s IntersectionObserver only observes the turns present in the initial mounted tail. Idle fill changes the transcript DOM without changing the promptRailTurns reference, so earlier turns are never observed. This matches the current prompt-rail.spec.ts CI failure: after scrolling to the beginning, the first tick never becomes active. Could the observer lifecycle follow the mount window/DOM membership so newly mounted turns are included?

  2. reconcileMountWindow() treats start > next.length as an invalid window, but start === next.length is invalid as well: turns.slice(start) renders an empty transcript until later idle fills recover it. Please clamp this boundary and add the equality case to the pure tests.

  3. The scroll-compensation formula has good unit coverage, but the behavior it protects depends on real Chromium layout and timing. Please add one focused E2E that pauses or observes the transcript while progressive fill is still running, keeps a historical turn as the reading anchor, and verifies that the same element’s viewport position does not jump as earlier chunks mount. The test does not need to require a constant scrollHeight or scrollbar-thumb size in this PR.

I’m treating the native scrollbar thumb shortening as the transcript grows, and broader fill/warm-up geometry stabilization, as non-blocking follow-up work. For this PR, preserving the reading anchor, restoring the prompt rail, covering the exact shrink boundary, retaining the measured first-commit improvement, and returning CI to green are sufficient.

Review items from apache#2191:

- the prompt rail's IntersectionObserver re-snapshots per fill step via
  mountedTurnsRevision, so turns mounted by the idle fill are observed
  and the first tick activates after scrolling to the beginning
- reconcileMountWindow also re-windows when the turn count shrinks
  exactly to the window start; that boundary sliced to an empty
  transcript
- the fill publishes data-progressive-fill on the scroller, and a new
  scroll-geometry E2E anchors on a historical turn and verifies its
  viewport position holds while earlier chunks mount
@Benjamin-eecs

Copy link
Copy Markdown
Contributor Author

All three closed in 1004abe:

  1. The rail's observer now re-snapshots per fill step: ChatView bumps a mountedTurnsRevision prop (the window start), so turns mounted by the idle fill are observed. prompt-rail.spec.ts passes locally, including the scroll-to-beginning first-tick case.
  2. reconcileMountWindow re-windows on start >= length (length > 0); the equality case and the empty-transcript case are in the pure tests.
  3. New scroll-geometry E2E: after a switch back, it samples a historical turn's viewport position while data-progressive-fill reports filling (a new scroller attribute, published the way data-turn-warmup is) and asserts the position holds within 2px once the fill completes. Bottom-lock re-pinning stays covered by the existing pinned settles.

Local runs: ui unit 18/18, scroll-geometry + prompt-rail 9/9. 7f77b13 removes a local measurement probe I committed by accident in the same push.

@Astro-Han

Copy link
Copy Markdown
Contributor

Thanks for addressing the three review items. The production changes look good to me now, and all other checks are passing.

The one remaining blocker is the new progressive-fill geometry E2E, which fails in CI with the reading anchor moving by 7,974px. The test sets scrollTop and immediately samples the anchor; under CI timing, Astryx may not have processed that scroll and released its bottom lock before the next fill changes scrollHeight, so it can re-pin the viewport to the bottom.

Could you make the test reliably establish the unlocked reading state before sampling the anchor, and keep the progressive-fill boundary distinct from the subsequent turn-size warm-up as needed? Once this test is deterministic and CI is green, I have no other blocking concerns with the PR.

The anchor test raced two things it could not see. Astryx's scroll
handler skips direction-based unlock whenever scrollHeight changed in
the same event, and the fill grows the document every step, so a
scripted upward scroll almost never unlocked the bottom follower and
the spring re-pinned the viewport. And any protocol round-trip between
asserting the filling state and installing the watch left a window for
the fill to finish first.

The watch now arms inside the page before the session switch is
dispatched, releases the follower through its wheel fast path plus an
upward write, proves the position holds across consecutive frames
before sampling, samples only while data-progressive-fill reports
filling, and fails loudly when the fill races instead of passing over
nothing.
@Benjamin-eecs

Benjamin-eecs commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in be56008; CI is green.

Cause: the fill grows the document every step, and Astryx ignores scroll direction when the height changed in the same event. So the test's scripted scroll rarely unlocked the follower, and the spring pulled the viewport back to the bottom.

The test now unlocks the follower properly, waits until the position actually holds, and measures only while the fill is running. If the fill finishes too fast, it fails with a clear message instead of passing without measuring.

Btw the prompt-rail failures I mentioned earlier also happen on clean main here, so that's just my local environment.

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.

perf(desktop): switching back to a long session mounts the whole transcript in one commit, freezing the UI ~0.35s

3 participants