Skip to content

fix(web): transcript ordering, scroll anchoring, and viewport fill on thread activation - #1761

Merged
chuks-qua merged 4 commits into
mainfrom
fix/transcript-ordering-scroll-fill
Sep 24, 2026
Merged

chuks-qua merged 4 commits into
mainfrom
fix/transcript-ordering-scroll-fill

Conversation

@chuks-qua

@chuks-qua chuks-qua commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

What

Four related transcript fixes/UX changes:

Narrative ordering on cold thread activation. The tail fast-path committed a narrative-free conversation.tail payload first, so the final response painted and reasoning popped in above it. Activation now fetches the tail and the narrative page concurrently and commits once; the tail stays as a failure fallback. Loaded narrative is no longer evicted when virtual rows unmount, and the last 10 assistant messages' narrative warms on activation.

Scroll anchoring during history prepends. The reading anchor resolved through provisional row heights (128px) while real narrative rows measure ~300-500px, so each measurement pass after a prepend drifted the viewport upward. The anchored row's screen position is now captured at user-scroll time and held against real DOM rects as measurements settle.

Viewport fill + earlier pagination. The two-message tail left most of the screen empty on open. A UI effect now tops up with older pages until the transcript covers 1.5x the viewport (bounded at 3 pages). History page size drops 50 to 25 and the boundary trigger moves from 200px to one viewport height, so the next page is resident before the edge. The pagination spinner only appears when a fetch exceeds 300ms.

Keep-alive transcript switching. A warm switch remounted the whole virtualized transcript: projection caches died with the component and vlist rebuilt ~15 row components before paint, costing 173-630ms against a ~3ms record commit. The five most recently selected transcripts now stay mounted, hidden with visibility:hidden + inert (not display:none, which would collapse the virtualizer to zero height and destroy every row). Hidden transcripts hold a residency display lease so records stay resident and self-heal after cache eviction; eviction past the LRU cap releases the lease back to the existing cache path. A resident target record is paintable immediately instead of waiting for the hydration commit.

Verified with unit tests (49/49 ChatView, 650+ across the conversation suites), typecheck, lint, and live Electron runs over CDP (videos attached).

Why

Thread switches visibly flashed the response before its reasoning; scrolling up through older history jumped toward the first message instead of staying on the reading row; the initial two-message paint left the transcript mostly empty; and warm thread switches rebuilt the entire transcript DOM, making the app feel sluggish.

UI Changes

  • thread-switch-ordering-2.mp4: cold/warm thread switches — narrative lands with or before the response
  • scroll-anchor.mp4: reading row stays pinned through history prepends
  • fill-viewport.mp4: viewport fill on open + seamless scroll-up pagination
  • keepalive-switch.mp4: warm thread switches now swap visibility instead of remounting (~70-135ms click-to-visible vs 173-630ms before)

Config Changes

None.

Test plan

  • transcript-viewport.test.ts 16/16 (new pinned-anchor regression test)
  • MessageList.thread-switch.test.tsx 34/34 (new viewport-fill test)
  • ChatView.test.tsx 49/49 (kept-alive retention, lease, LRU eviction, hold/transition stages)
  • thread-hydrator.test.ts 73/73, pagination, message-cap, message-cache green
  • tsc --noEmit and oxlint clean
  • Live Electron: cold open fills ~6x viewport in ~3s; scroll-up loads resident ~1 viewport early; no ordering inversions; warm switches ~70-135ms; LRU cap holds at 5 mounted transcripts; memory flat across churn
thread-switch-ordering-2.mp4
scroll-anchor.mp4
fill-viewport.mp4
keepalive-switch.mp4

…eading row

Cold activation committed a narrative-free tail page before the richer
conversation page resolved, so the response painted first and reasoning
popped in above it. Fetch the page concurrently and commit once; the tail
remains a failure fallback. Keep loaded narrative resident instead of
evicting on row unmount, and warm the tail's narrative on activation.

Also pin the reading row's screen position captured at user-scroll time.
Resolving the anchor through provisional heights drifted on every
measurement pass after a prepend, sliding the viewport upward.
…lier

The two-message tail paints fast but leaves most of the screen empty.
Top up with older pages after each commit until the transcript covers
1.5x the viewport (bounded at three pages). Page size drops to 25 and
the boundary trigger moves out to one viewport height so the next page
is resident before the edge. The pagination spinner now only appears
when a fetch exceeds 300ms.
…ead switches

A warm thread switch remounted the whole virtualized transcript: the
projection caches died with the component and vlist rebuilt ~15 row
components before the transcript could paint, so revisiting a thread cost
173-630ms even though the record commit was ~3ms.

Keep the five most recently selected transcripts mounted, hidden with
visibility:hidden and inert instead of display:none (which would collapse
the virtualizer to zero height and destroy every row). Hidden transcripts
hold a residency display lease so their records stay resident and
self-heal after cache eviction; eviction past the LRU cap releases the
lease and falls back to the existing cache path. A resident target record
is now paintable immediately instead of waiting for the hydration commit.

Measured on a production build with 150-message threads: warm switches
drop to ~70-135ms; memory stays bounded at ~2MB per retained transcript
and flat across churn.
@chuks-qua
chuks-qua merged commit 30db965 into main Sep 24, 2026
6 checks passed
@chuks-qua
chuks-qua deleted the fix/transcript-ordering-scroll-fill branch September 24, 2026 19:03
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.

1 participant