perf(ui): seed transcript geometry before progressive fill - #2237
Conversation
Astro-Han
left a comment
There was a problem hiding this comment.
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-turnGeometryis 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.
|
Thanks for the careful pass. All three P2s are handled in 0455242:
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. |
|
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
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. |
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.
…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>
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.contain-intrinsic-sizefrom the record. The scroller's total height stays put while chunks replace the spacer, and seeded turns skip the warm-up walk.Two timing details worth calling out: the spacer sets
transition: nonebecause 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/uiunit tests pass, with new coverage for the index, the prefix arithmetic, geometry measurement, and the warm-up skip.apps/desktopscroll-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.