Skip to content

perf(ui): seed transcript geometry before progressive fill - #2237

Merged
Astro-Han merged 3 commits into
apache:mainfrom
Benjamin-eecs:fix/2224-seeded-transcript-geometry
Aug 5, 2026
Merged

perf(ui): seed transcript geometry before progressive fill#2237
Astro-Han merged 3 commits into
apache:mainfrom
Benjamin-eecs:fix/2224-seeded-transcript-geometry

Conversation

@Benjamin-eecs

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

Copy link
Copy Markdown
Contributor

Summary

Closes #2224

Returning to a session rebuilds the transcript from a 10-turn tail, so during the progressive fill the document grows chunk by chunk and the scrollbar keeps moving. This PR remembers turn heights once a session settles and uses them on the next visit:

  • turn-size-index: a small in-memory LRU keyed by session and layout (reading-column width plus density). Geometry is recorded after the fill and warm-up finish, and only if the layout held still in between, since stale remembered sizes would poison the record.
  • On return, one spacer stands in for the unmounted prefix and each turn seeds its contain-intrinsic-size from the record. The scroller's total height stays put while chunks replace the spacer, and seeded turns skip the warm-up walk.
  • Without a record (first visit, resized window) everything degrades to the plain perf(desktop): switching back to a long session mounts the whole transcript in one commit, freezing the UI ~0.35s #2052 fill.

Two timing details worth calling out: the spacer sets transition: none because the app-wide transition rule delays height changes past the commit that mounts a chunk, which broke same-frame scroll compensation; and the lookup retries until the fill window closes, because on platforms with classic scrollbars the column stays wider until enough turns mount to overflow.

Verification

  • packages/ui unit tests pass, with new coverage for the index, the prefix arithmetic, geometry measurement, and the warm-up skip.
  • apps/desktop scroll-geometry e2e suite passes. The reading-anchor test now drives the full journey (settle, record, leave, return under 20x CPU throttle) and asserts the anchored turn holds still and the total height stays within rounding while the fill runs.

@Benjamin-eecs
Benjamin-eecs marked this pull request as ready for review August 5, 2026 11:58
Copilot AI lite review requested due to automatic review settings August 5, 2026 11:58

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.

@Astro-Han Astro-Han 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.

Thanks for this PR — the design reads very cleanly. A single spacer as the minimal geometric stand-in for the unmounted prefix, seeded contain-intrinsic-size per turn, and the layout-keyed record with the "settle + unchanged layout" gate are all exactly the right abstractions, and the arithmetic checks out (the 355 gap-boundary unit test is a particularly nice pin). I ran the packages/ui suite and reviewed the full diff: no P0/P1, two P2s, a handful of P3s. Per the review gate, the P2s need handling (fix or explicit deferral with a reason); the P3s are optional.

P2-1 — the record polling never terminates on an empty session (chat-view.tsx:441, :461)
measure() re-arms itself while data-turnWarmup !== 'settled', but 'settled' is only ever written from the warm-up path, which early-returns when there are no turns (use-chat-scroll.ts:50, :66). An empty session satisfies the effect guards (turnsFilled is true for zero turns), so the "新任务" landing surface keeps a permanent 5 Hz timer that reads layout every tick. Cheap in practice, but it's unbounded work on the app's resting surface. Fix is small: gate the initial setTimeout on orderedTurnIds.length >= 2 (and/or add a poll budget as a backstop).

P2-2 — the retry semantics don't match the claim in the last commit / PR description
The commit "retry the geometry lookup until the fill window closes" and the description's classic-scrollbar rationale describe a retry loop, but the nudge effect (chat-view.tsx:419) bumps lookupPass at most once per dependency change — fill progress isn't in the deps, so the classic-scrollbar width convergence is never actually retried; and if the nudge fires while scrollRef.current is still null, it never fires again. The in-place switch path works (and the e2e covers it), but the mechanism as documented isn't what ships. Either fold the lookup into the fill's idle callback so a late seed lands on a compensated commit, or adjust the docs/commit message to the real "at most one retry per switch" semantics and add a test that pins it.

P2-3 — the record guards have no deterministic coverage (chat-view.tsx:439, :446, :458)
The layout-stability gate, the streaming-turn skip, and the data-turn-geometry publish are the invariants that prevent cache poisoning, but they're inline in the effect and the e2e only catches their failure on a specific machine-timing chain. Extracting the measure+guard into a pure function (e.g. measureSettledGeometry(root, layoutKey)) with unit tests would make the "no record when layout moved" contract deterministic.

P3s (all optional):

  • P3-1: Interleaved elements (plan cards) aren't captured by heights + median gap; cumulative offsets per turn would make the spacer exact and O(1), and would also relax the all-or-nothing prefix rule for sessions that grew between visits (turn-size-index.ts:78-90).
  • P3-2: Stale seeds (content changed between visits, e.g. late images) are skipped by the warm-up (turn-size-warmup.ts:87); bounded per visit and self-healing, just worth a comment noting the trade-off.
  • P3-3: data-turnGeometry is never cleaned up on session switch (chat-view.tsx:458), and the e2e key-match doesn't verify the session dimension.
  • P3-4: The LRU docstring says "bounded" but the bound is per-session, not per-entry — worth one sentence of clarification.

Verdict: PASS with conditions — happy to approve once P2-1 is fixed and P2-2 is either implemented as documented or re-documented (P2-3 optional but cheap). Thanks again — genuinely nice seam extension, no second scroll container, no parallel state.

@Benjamin-eecs

Copy link
Copy Markdown
Contributor Author

Thanks for the careful pass. All three P2s are handled in 0455242:

  • P2-1: the record effect now requires a measurable pair of turns, which also closes the empty-session hole (no turns means no warm-up and no 'settled'), plus a poll budget as backstop.
  • P2-2: implemented as documented now. The nudge retries after every fill commit until the lookup hits or the window closes, which also covers the null-ref first pass on fresh mounts. Verified against classic scrollbars on a Linux box before pushing.
  • P2-3: measurement and its guards moved to measureSettledGeometry with unit tests driving each exit (pending, layout moved, streaming skip, no pair).

P3-2, P3-3 and P3-4 are in the same commit: the stale-seed trade-off comment, the published key is cleared when the transcript it described goes away, and the LRU docstring now says the bound is per session. P3-1: agreed cumulative offsets are the better final shape, and they would relax the all-or-nothing prefix rule too. Deferring it so this PR stays the minimal seam; happy to take it as a follow-up.

@Astro-Han

Copy link
Copy Markdown
Contributor

Thanks for the careful handling of the previous round — the pair-of-turns gate for the empty-session hole, the retry semantics now matching the docs, and the extracted measureSettledGeometry guards all landed exactly as discussed, and the new unit tests pin them well. I re-reviewed the current head (0455242) and ran the full packages/ui suite (364/364). Three follow-ups surfaced, all non-blocking — happy to leave any of them as a follow-up issue rather than hold this PR:

  1. The retry now fires on first visits where it can never hit (chat-view.tsx:404-412). The nudge's deps include mountStart, so it re-runs on every fill chunk — but on a first visit the index has no record until the fill completes and the warm-up settles, so every retry is a doomed full lookup/render pass. The docs now match the code, but the code pays for the retry on the most common path. A cheap has(sessionId) gate on the index (or capturing a stable boolean in the memo) would make the retries fire only when a record can actually exist. This is a perf-PR-internal cost, not a behavior bug — deferrable.

  2. The e2e no longer watches the unseeded fill (scroll-geometry.spec.ts). The priming round trip guarantees the watched return is always seeded, and under a seeded fill the document height is constant by construction — so compensateFillScroll's delta is ≈0, and the anchor-preservation contract the old journey pinned (the perf(desktop): switching back to a long session mounts the whole transcript in one commit, freezing the UI ~0.35s #2052 one: unseeded fill, growing document, compensation keeping the anchor) is now covered by no test. I verified that replacing the compensation with scrollTop: before.scrollTop passes the new e2e. Apologies we didn't catch this narrowing in the earlier pass — it surfaced when re-checking what the old test was guarding. Worth restoring one watched unseeded journey (e.g. collapse the sidebar between the priming trip and the return so the layout key misses) or tracking it explicitly; either is fine, nothing here blocks the PR.

  3. Minor, optional: a record taken while the window is resized mid-fill can cement old-width seeds into the index (the warm-up skips seeded turns, and getBoundingClientRect on them returns the stale placeholder, which is then re-recorded under the new layout key). Narrow trigger window (~1s fill), visual scrollbar-height error only, self-corrects once the turns actually render. A comment noting the trade, or skipping never-rendered seeded turns on re-record, would close it.

None of these block merging — the mechanism is sound, the seeded path is a real improvement, and the regression coverage for what this PR changes is solid. Happy to approve.

@Astro-Han
Astro-Han merged commit 6c7efe5 into apache:main Aug 5, 2026
12 checks passed
cat0825 added a commit to cat0825/maka-agent that referenced this pull request Aug 5, 2026
Root causes of the CI e2e failures (this spec had never run on Linux
before the upstream-main merge):

1. Button names are locale-dependent — Maka's Astryx copy overrides
   (astryx-copy.ts) map 'New messages'/'Scroll to bottom' to zh
   ('跳到最新消息'/'滚动到底部') under the default zh fixture locale.
   The spec hardcoded the en names, so both scroll-button assertions
   could not find the button. Match both locales — the assertion is
   about the affordance, not Astryx's copy.

2. The prompt anchor rail (upstream apache#2237) renders a preview of the
   sent text, so loose getByText(/Fake backend received: .../) regexes
   matched two nodes (preview + transcript echo) → strict-mode
   violation. Assert with { exact: true } on the echo, which the longer
   preview never matches.

3. growTranscriptOverflow only required sh > ch (merely scrollable);
   Astryx flags scrolled-up only past buttonThreshold (100px), so a
   compact font could leave the button unrendered. Grow until
   sh - ch > 150px (threshold + headroom), up to 6 messages.

Verified locally: 2/2 pass.
Astro-Han pushed a commit that referenced this pull request Aug 5, 2026
…sation (#2205) (#2211)

* fix(desktop): dismiss "New messages" indicator at bottom and per conversation (#2205)

Astryx ChatLayout only cleared hasNewMessages through the button's
dismiss(), so scrolling back to the bottom re-locked auto-follow but left
the "New messages" label visible; and a conversation switch reused the
same ChatLayout instance, leaking hasNewMessages, lastMessageRef and the
unlocked scroll state into the new conversation, whose first message then
re-triggered the indicator.

- patches/@astryxdesign+core+0.2.0.patch: ChatLayout clears the flag on
  every re-lock (scrollend within the lock threshold) and gains an
  optional conversationKey prop that resets the scroll lock and the
  new-message baseline when it changes; useChatNewMessages exposes reset().
- ChatSurfaceLayout forwards conversationKey from the active session id
  (app-shell + quote companion). No remount: a remount would drop an
  in-progress composer draft, a regression composer-skill-invocation e2e
  caught on the earlier keyed-remount attempt.
- e2e regression: new-messages-indicator.spec.ts passes with the fix and
  fails without it.

* fix(e2e): make new-messages-indicator spec locale-agnostic and CI-robust

Root causes of the CI e2e failures (this spec had never run on Linux
before the upstream-main merge):

1. Button names are locale-dependent — Maka's Astryx copy overrides
   (astryx-copy.ts) map 'New messages'/'Scroll to bottom' to zh
   ('跳到最新消息'/'滚动到底部') under the default zh fixture locale.
   The spec hardcoded the en names, so both scroll-button assertions
   could not find the button. Match both locales — the assertion is
   about the affordance, not Astryx's copy.

2. The prompt anchor rail (upstream #2237) renders a preview of the
   sent text, so loose getByText(/Fake backend received: .../) regexes
   matched two nodes (preview + transcript echo) → strict-mode
   violation. Assert with { exact: true } on the echo, which the longer
   preview never matches.

3. growTranscriptOverflow only required sh > ch (merely scrollable);
   Astryx flags scrolled-up only past buttonThreshold (100px), so a
   compact font could leave the button unrendered. Grow until
   sh - ch > 150px (threshold + headroom), up to 6 messages.

Verified locally: 2/2 pass.

---------

Co-authored-by: cat0825 <cat0825@users.noreply.github.com>
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(ui): seed transcript geometry before progressive fill

3 participants