Skip to content

fix(ui): land a switched-to session at its latest turn instead of flying there - #2239

Merged
Astro-Han merged 10 commits into
apache:mainfrom
ARE404:are404/fix-session-switch-scroll
Aug 5, 2026
Merged

fix(ui): land a switched-to session at its latest turn instead of flying there#2239
Astro-Han merged 10 commits into
apache:mainfrom
ARE404:are404/fix-session-switch-scroll

Conversation

@ARE404

@ARE404 ARE404 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Problem

Switching sessions plays a visible scroll animation: the new transcript appears near its top and travels down to the latest turn. On the 24-turn long-transcript fixture the scroller covers 10844px over ~1.2s in front of the reader, sampled every frame across the switch:

t=31   turns=10  scrollTop=0      scrollHeight=2976    distanceFromBottom=2196
t=52   turns=10  scrollTop=88     scrollHeight=11712   distanceFromBottom=10844
t=170  turns=24  scrollTop=8935   scrollHeight=15436   distanceFromBottom=5722
t=263  turns=24  scrollTop=27020  scrollHeight=32908   distanceFromBottom=5109
...
t=1208 turns=24  scrollTop=32128  scrollHeight=32908   distanceFromBottom=0

Cause

Astryx's useChatStreamScroll positions the first fill of its scroller instantly and springs every later growth. That one-shot (initialFillPendingRef) lives on the hook instance, and ChatSurfaceLayout mounts once for the whole app shell — app-shell.tsx toggles hidden, it never remounts — so the instant path is spent on whichever session was open at boot. Every switch afterwards is "later growth".

And a switched-to transcript does not arrive in one piece. #2191's progressive mount commits a tail window, idle chunks fill the prefix, and the content-visibility warm-up (#827) then inflates every 250px placeholder. Each step grows the document under a scroller the spring is already chasing — which is why the flight lasts a second rather than a frame, and why the trace above shows the spring restarting at every jump in scrollHeight.

useChatScroll wrote scrollTop = scrollHeight once on the session change, which is too early to help: at that point the switched-to transcript is still an empty scroller.

Fix

packages/ui/src/arrival-bottom-pin.ts — a bottom pin scoped to the arrival window of a switched-to transcript.

It writes scrollTop from a ResizeObserver on the message list, i.e. after layout and before paint, so the growth the spring would have animated is already consumed by the time a frame is painted and the spring settles against a zero delta instead of running.

It owns the arrival and nothing else:

  • released when the warm-up reports the geometry settled — steady-state following (streaming, appended turns) stays Astryx's, exactly as refactor(ui): migrate chat surfaces to Astryx layout #1795 intended;
  • released on any sign the reader took control, using the signals Astryx itself unlocks on: an upward wheel, a touch drag, or a scroll that moved up while the geometry held still. The last qualifier is the load-bearing one — the arrival window is nothing but resizes, and Chromium fires a synthetic scroll for each of them;
  • released on turn navigation (search / revision targets), where the reader has chosen a position.

The pin publishes data-arrival-pin on the scroller, the way the warm-up publishes data-turn-warmup and the fill publishes data-progressive-fill, so a test can wait on the real boundary instead of guessing at timing.

After the fix, the same switch, same sampling:

t=41  pin=pinned    turns=10  scrollTop=2196   scrollHeight=2976   distanceFromBottom=0
t=149 pin=pinned    turns=24  scrollTop=13564  scrollHeight=14344  distanceFromBottom=0
t=209 pin=pinned    turns=24  scrollTop=32128  scrollHeight=32908  distanceFromBottom=0
t=332 pin=released

Tests

  • packages/ui/src/__tests__/arrival-bottom-pin.test.ts — 9 cases over the pure module: follows every growth step, ignores the synthetic scroll a resize fires, holds through a sub-pixel readback of its own write, releases permanently on reader intent, detaches on dispose.
  • apps/desktop/e2e/scroll-geometry.spec.tsa session switch lands on the latest turn instead of flying to it. Sampled per frame rather than polled, because the regression is an animation and a poll reads it as a sequence of individually reasonable positions. It asserts the transcript is flush in every frame it exists, and that the document actually grew under the watch, so a run that measured nothing fails loudly. On the parent commit it fails with maxDistance=11267; here it reports 0.

Verified locally: format:check, lint, typecheck, knip --workspace packages/ui, @maka/ui (352 tests), and the full scroll-geometry suite (8/8) — including progressive fill preserves the reading anchor while earlier turns mount, which is the case where the pin has to get out of the way.

Notes for review

  • Why not fix this in Astryx: the layout has no notion of "the conversation changed", which is the only thing that distinguishes navigation from growth. Its own instant path is already correct for what it can see.
  • Relationship to perf(ui): seed transcript geometry before progressive fill #2224: orthogonal. perf(ui): seed transcript geometry before progressive fill #2224 seeds the missing geometry so the scrollbar stops resizing during the fill; this keeps the viewport flush while it does. Both survive the other landing first — if the prefix stops growing the document, the pin simply has less to consume.
  • One implementation note worth knowing: this has to be a passive effect. ChatLayout passes ref={mergeRefs(ref, rootRef)}, a fresh callback ref each render, so React detaches and reattaches its root ref around every commit and scrollContainerRef.current is null while a child's layout effects run. That is the same reason the warm-up effect below it is passive.

Reported by a user watching every session switch scroll itself into place.

🤖 Generated with Claude Code

…ing there

Switching sessions played a visible scroll animation: the new transcript
appeared near its top and travelled down to the latest turn over ~1.2s.
Measured on the 24-turn scroll-geometry fixture, the scroller covered
10844px of that flight in front of the reader.

Astryx's `useChatStreamScroll` positions the FIRST fill of its scroller
instantly and springs every later growth. That one-shot lives on the hook
instance, and `ChatSurfaceLayout` mounts once for the whole app shell, so
it is spent on whichever session was open at boot. Every switch after that
counts as "later growth" — and a switched-to transcript does not arrive in
one piece: the apache#2052 progressive mount commits a tail window, idle chunks
fill the prefix, and the content-visibility warm-up then inflates every
placeholder. Each step grew the document under a scroller the spring was
chasing, so the session opened mid-document and animated to its end.

`useChatScroll` wrote `scrollTop` once on the session change, which is too
early to help: at that point the switched-to transcript is still an empty
scroller, and every piece that lands afterwards restarts the flight.

An arrival-scoped bottom pin consumes those growth steps instead. It
writes `scrollTop` from a ResizeObserver on the message list — after
layout, before paint — so the growth the spring would have animated is
already spent by the time a frame is painted, and the spring settles
against a zero delta rather than running. It owns the arrival window only:
the warm-up releases it once the geometry settles, and steady-state
following (streaming, appended turns) stays Astryx's, as does everything
after the reader takes over. Any sign they did takes the pin off for good,
using the signals Astryx itself unlocks on — an upward wheel, a touch
drag, or a scroll that moved up while the geometry held still, since the
arrival window is nothing but resizes and Chromium fires a synthetic
scroll for each one. Turn navigation releases it too.

The pin publishes `data-arrival-pin` the way the warm-up publishes
`data-turn-warmup`, so a test can wait on the real boundary. The new E2E
samples the scroller every frame across a switch — a polled probe reads an
animation as a sequence of reasonable positions — and asserts the
transcript is flush in every frame it exists, with the document proven to
have grown under the watch. It fails on the parent commit with
maxDistance=11267 and passes here with 0.
@Astro-Han

Copy link
Copy Markdown
Contributor

Thanks for the fix and the detailed write-up — the trace-first diagnosis is great, and I verified the key claims against the Astryx source: the one-shot initialFillPendingRef spent on the boot session, the layout exposing no "conversation changed" notion, and the warm-up's imperative growth being invisible to React effects. The arrival-scoped ResizeObserver pin is the right mechanism, and the passive-effect note matches what React actually does with the fresh merged callback ref.

I ran the new unit tests (9/9 pass) and reviewed the e2e. The regression coverage is solid — per-frame sampling is a real upgrade over polling. A few non-blocking suggestions:

P2 — worth a quick add

  1. The onScroll integration path is untested: no unit case drives growth → synthetic scroll → reader scrolls up through a real pin instance. If the last* refresh in the geometry-changed branch were dropped, all 9 unit tests plus the e2e would still pass while reader takeover after growth would silently break. One extra case would pin it.
  2. The e2e watcher checks its deadline only inside the rAF callback; a stalled compositor would hit the 60s Playwright timeout with no diagnostics — while climbToTop in the same file deliberately guards against exactly that. A rAF-independent watchdog (a setTimeout reject) would match the suite's own pattern.

P3 — optional polish

  • touchmove releases on any touch and wheel listens on the scroller root (composer/dock wheels bubble in), so the pin can release without evidence the reader took control of the transcript; Astryx gates the same signals on its animation state. Upward drags are already caught by onScroll, so these could be tightened to gestures that actually move content.
  • The unit fake doesn't clamp scrollTop, so the growth assertions pin the implementation (scrollTop = scrollHeight) rather than the flush contract; a clamping fake asserting scrollHeight - scrollTop - clientHeight === 0 would be more faithful.
  • Minor: deltaY = 0 is untested; frames is collected but never asserted; and the use-chat-scroll wiring (hasTurns gate, release on turn navigation, dataset cleanup) has no unit coverage — the repo already has React-test conventions to reuse there.

None of these block the fix — happy to help with any of them.

ARE404 added 6 commits August 6, 2026 00:37
Review item 1 on apache#2239: no unit case drove growth through a real pin
instance into the synthetic scroll Chromium fires for it, and out the
other side into the reader taking over. Dropping the geometry snapshot
refresh in the scroll handler's growth branch left all nine cases and the
E2E green while reader takeover after growth silently broke.

Two cases, one per half. The first rides a growth the observer reports:
the pin follows it, ignores the resize's own scroll event, and still
releases on the upward scroll that follows. The second covers growth the
observer never sees — the dock lives inside the scroller but outside the
observed message list, so it moves scrollHeight with nothing but a scroll
event — and fails without that refresh, which is the mutant the first
case cannot catch on its own.
Review item 2 on apache#2239: the watch checked its 30s deadline only inside
the rAF callback, so it could only expire while frames were arriving. A
compositor that stops ticking never reaches it, and the run dies on the
60s Playwright timeout instead — 'Target page closed', which says nothing
about what the scroller did. climbToTop in this same file already guards
its frame waits for exactly this reason.

A setTimeout now rejects independently of the frame clock, both exits run
through one settled flag, and every rejection carries the counters the
watch had collected.
Review item 3 on apache#2239: the fake let scrollTop hold any value written to
it, so the growth cases asserted the implementation's literal write
(scrollTop === scrollHeight) rather than the outcome that write is for.
The fake now clamps to scrollHeight - clientHeight the way a scroller
does, and the cases read distanceFromBottom === 0 — which a pin that
overshot, or one that stopped short, both fail.
Review item 4 on apache#2239: only an upward wheel was covered as "holds", so
a threshold written as `deltaY <= 0` would have passed. A horizontal
wheel reports deltaY 0 — a sideways swipe across a wide code block or
table is not the reader leaving the latest turn.
Review item 5 on apache#2239: the watch collected a frame count and never
asserted it. It is now the sampling-density guard — frames must exceed
the number of distinct heights, so at least some samples landed on a
quiet frame rather than on the moments the document changed. A spring
mid-flight lives exactly in those quiet frames, which is what makes the
flush assertion below evidence.
…ranscript

Review item 6 on apache#2239: the wheel and touch handlers listen on the
scroller root, and the dock — composer, plan panel, graph status — is
inside it, so a wheel over the composer or any touch anywhere released
the pin without evidence the reader had left the latest turn. Astryx
gates the same two signals on its animation state; the equivalent here is
where the gesture happened.

Both now require the event to have started inside the observed transcript
element. Nothing is lost by being strict: these handlers only exist to
beat the scroll event they cause, and a gesture that really moves the
scroller still reaches the scroll handler, which decides on what the
geometry did rather than on where the pointer was. That fallback is what
the new dock case asserts, so the strictness cannot silently cost the
reader control.
@ARE404

ARE404 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — all six suggestions are in, one commit each, pushed on top of the original.

P2

  1. test(ui): cover the arrival pin's scroll-event path end to end — two cases. The first drives a real pin instance through growth → the resize's own synthetic scroll → the reader's upward scroll after it. The second is the one that actually kills the mutant you named: growth the observer never reports. The dock lives inside the scroller but outside the observed message list, so it moves scrollHeight with nothing but a scroll event, and dropping the snapshot refresh leaves the next genuine upward scroll compared against a stale height. I checked by deleting those lines: 10 pass, that one fails.
  2. test(e2e): give the arrival watch a watchdog off the frame clock — a setTimeout rejects independently of rAF, both exits run through one settled flag, and every rejection carries the counters, matching climbToTop's guard.

P3

  1. test(ui): assert the pin's flush contract against a clamping viewport — the fake clamps to scrollHeight - clientHeight, and the growth cases now read distanceFromBottom === 0, which a pin that overshot and one that stopped short both fail.
  2. test(ui): pin the wheel threshold at zero delta — a deltaY <= 0 threshold would have passed before; a horizontal wheel reports 0, and a sideways swipe across a wide code block is not the reader leaving the turn.
  3. test(e2e): assert the arrival watch sampled between growth stepsframes is now the sampling-density guard: it must exceed the number of distinct heights, so some samples landed on quiet frames. A spring mid-flight lives exactly there, which is what makes the flush assertion evidence rather than a snapshot of the moments the document moved.
  4. fix(ui): scope the arrival pin's eager release to gestures over the transcript — both gestures now require the event to have started inside the observed transcript element. Worth stating why this is safe rather than merely stricter: these handlers exist only to beat the scroll event they cause, so a wheel over the composer that really does scroll the transcript still releases the pin, through that scroll event. The new dock case asserts exactly that fallback, so the strictness cannot silently cost the reader control.

On the use-chat-scroll wiring — I tried to cover it and am leaving it uncovered deliberately, so the gap is on the record rather than implied:

  • The hasTurns gate and the data-arrival-pin publish/cleanup are exercised by the E2E, which waits on that attribute and would hang if it never appeared or never cleared.
  • The release on turn navigation is not covered. The faithful scenario is a search hit landing in another session — the switch and the target arrive together, mid-arrival, which is precisely when holding would be wrong — but getByRole('option') finds nothing for fixture-seeded transcripts: they are written straight to storage, so they never reach the search index. Making that path drivable means changing the fixture, which felt like more surface than this PR should move. Happy to do it as a follow-up if you'd rather have it.
  • A unit test instead would mean running effects, and every React test in packages/ui is renderToStaticMarkup, which never does. Introducing a DOM harness for three lines seemed worse than saying so here.

Local: format:check, lint, typecheck, knip --workspace packages/ui, @maka/ui (358 tests), scroll-geometry 8/8 — including progressive fill preserves the reading anchor, which is the case where the tightened gesture path has to fall through to the scroll handler and does.

@Astro-Han

Copy link
Copy Markdown
Contributor

Thanks — all six are in and verified, and I checked them one by one. The P2-1 pair is exactly as advertised: I confirmed by removing the snapshot refresh that exactly one test fails (takes its geometry snapshot from growth it did not write itself) — the first case alone can't catch that mutant, so the second case earns its lines. The watchdog runs off the frame clock with both exits through the settled flag, the clamp assertions distinguish stop-short correctly, the zero-delta loop pins the threshold exactly (deltaY < 0), and the sampling-density guard is sound given idle-chunked warm-up. The scoping mechanism itself is right: content.contains(target) with permissive fallbacks, text-node targets are safe, touch targets follow the touchstart element, the scroll handler is a complete fallback, and the message-list element is the correct content (the dock really is inside the scroller root). Two items to handle — both small:

P2 — the scoping commit silently turned the progressive-fill e2e's unlock into a race. tryHold dispatches its WheelEvent(deltaY: -120) on the scroller root (scroll-geometry.spec.ts:486), but the pin's eager release now requires the gesture to have started inside the message list — contains(root) is false, so that wheel no longer releases the pin. The only remaining path is the scroll event from root.scrollTop = mid, which releasesArrivalPin swallows whenever a growth's ResizeObserver lands in the same rendering update (re-pin to bottom before the scroll event dispatches). The loop retries ≤20 times and CI runs no retries, so the test now depends on a growth-free frame landing between a write and its scroll event — green locally (as your 8/8 showed) but flake-prone under CI's CPU throttle. One-line fix: dispatch the wheel at the message list (the pin's content element) in tryHold, which preserves the intent. (So "nothing is lost by being strict" holds for production behavior but not for the e2e's unlock mechanism.)

P2 — the stated reason for leaving turn-navigation release uncovered doesn't hold. The gap itself is fine to defer, but "fixture-seeded transcripts are written straight to storage and never reach the search index" isn't right: fixture headers default to backend: 'ai-sdk' (only the stale-sessions fixture uses fake), and runThreadSearch excludes only backend === 'fake' while scanning storage — the long-transcript fixture's title (超长会话滚动几何) matches a 长会话 query, and floating-layers.spec already proves the search modal returns options in fixture windows. So the coverage was cheap: switch to session A, open search, click a hit in session B, assert no flight (the release() in use-chat-scroll's target effect). Either add that e2e or restate the deferral with the corrected reason.

P3 (optional): the watchdog's deadline-fail path leaves the timer and rAF loop running (dangling 35s timer in a closing page — harmless); d82f242's message overstates — an overshooting pin does not fail the new clamp assertions (overshoot is unobservable behind the clamp, as in a real scroller; the old literal assertion caught mutants the new one can't, but those mutants are behaviorally inert); "data-arrival-pin cleanup is exercised by e2e" is overstated — the delete is never observed (the attribute is re-published by the next pin in the same commit); dock-gesture release can be delayed up to one growth (documented in 4dfa130 and consistent with Astryx's own gating — accepted trade-off).

The original P2/P3 list is fully addressed and the fix itself is sound. Approving — happy to re-review the tryHold scoping if you take it.

ARE404 added 3 commits August 6, 2026 01:15
Follow-up on apache#2239: scoping the pin's eager release to gestures over the
transcript turned this test's unlock into a race. tryHold dispatches its
WheelEvent on the scroller root, which is no longer inside the pin's
content element, so the wheel stopped releasing the pin and the only
remaining path was the upward write's scroll event — which the pin
ignores whenever a growth lands in the same rendering update. The loop
retries at most 20 times and CI runs no retries, so the test was left
depending on a growth-free frame arriving in time.

Dispatching at the message list restores the intent: the gesture is over
the transcript, which is what a reader's wheel is. Astryx's own unlock is
unaffected either way.
Review nit on apache#2239: the deadline rejection left the 35s watchdog armed
and the rAF loop running in a page that is about to close. Harmless, but
both exits should look the same — they now clear the timer and the loop
returns on the settled flag.
Review nit on apache#2239: d82f242's note claimed the clamp catches a pin
that overshoots. It does not — a real scroller clamps too, so overshoot
is unobservable on both sides, which is why it is also behaviourally
inert. The clamp earns its place in the other direction: it turns any
arithmetic that stops short into a distance, independently of how the
value was computed.
@ARE404

ARE404 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the approval, and for catching the tryHold regression — you were right that "nothing is lost by being strict" stopped at the production boundary and I hadn't followed it into the test that depends on the same mechanism.

1. tryHold — fixed (c9ad81f38). The wheel is now dispatched at .maka-chat-message-list rather than the scroller root, so it is a gesture over the transcript again and the eager release fires on the first attempt instead of waiting for a growth-free frame. Astryx's own unlock takes it either way, since it doesn't care where the wheel started. Ran the anchor test three times at the existing 20x throttle: 5.8s / 6.1s / 6.2s, all green.

2. The deferral reason — you're right that mine was wrong, and the corrected one still lands somewhere else. I re-checked against the code and then against a live fixture window instead of arguing from either.

You're right on both facts I got wrong: there is no index to miss (runThreadSearch scans storage live through runtime.getMessages), and fixture headers default to backend: 'ai-sdk', so the !== 'fake' filter doesn't exclude them. Your title query does return a hit — I confirmed it:

query '长会话'     → [{"title":"超长会话滚动几何","summary":"会话标题",
                      "target":{"kind":"thread","sessionId":"e2e-fixture-long-transcript"}}]
query '长会话问题'   → []
query '长会话问题 3' → []
query '长会话回答 3' → []
query '占位正文'     → []

So the corrected reason is narrower than my original one and narrower than "it works": in the long-transcript fixture, title hits come back and content hits do not. And a title hit's target carries no turnId — the ...(turnId ? { turnId } : {}) spread — so clicking it dispatches a plain session switch, which is the case the arrival E2E already covers. The turn-navigation release needs a hit with a turnId, and that needs a content hit.

I don't want to assert a cause I haven't proven. What I can say is black-box: the transcript renders 24 turns from the same session while search:thread returns nothing for any string in them, and the one structural difference I can see is that search reads through runtime.getMessages while the fixture seeds files on disk. If that's a real gap it's worth its own issue — happy to file it with the probe above, and to add the turn-navigation E2E on top once a content hit is reachable. Until then the gap stands, with this as the reason on the record rather than the one I gave.

3. P3 nits — both taken. 286b01e55: the deadline path now clears the watchdog and the loop returns on the settled flag, so neither exit leaves anything armed. 2b9719eb7: corrected the claim in the fake's doc — a real scroller clamps too, so overshoot is unobservable on both sides (and inert); what the clamp actually buys is that stopping short shows up as a distance whatever arithmetic produced it. On data-arrival-pin cleanup: agreed, the delete is never observed, since the next pin republishes it in the same commit — the E2E covers the publish, not the cleanup, and I've stopped claiming otherwise. The one-growth delay on dock gestures is accepted as documented.

Local after the three commits: format:check, lint, typecheck, @maka/ui 358, scroll-geometry 8/8.

@Astro-Han
Astro-Han merged commit 85a7d7a into apache:main Aug 5, 2026
12 checks passed
@ARE404

ARE404 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Filed the search finding as #2305 — probe output, the turnId-only-on-content-hits consequence, and the runtime.getMessages vs seeded-files hypothesis marked as a hypothesis rather than a diagnosis. The turn-navigation gap here now points at it as its reason.

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.

2 participants