When opening an agent transcript, the feed visibly scrolls/jumps to the last message — often several times in the first second — instead of simply opening at the last page the way ChatGPT/Claude conversations do. Traced through the code; the motion is a designed-in re-pin loop, and the commercial-grade fix is to make the first visible frame already be the bottom.
Root cause
Open sequence today (desktop/src/surfaces/AgentTranscript.tsx):
- Up to 500 events load (
LOAD_TAIL = 500, :25,137), then Virtuoso mounts with initialTopMostItemIndex={{ index: liveData.length - 1, align: 'end' }} (:396). At mount all row heights are estimates.
- Over the next frames, rows measure and hydrate — markdown, code blocks, KaTeX, async variable fonts, and images rendered with no width/height attributes (
desktop/src/ui/Markdown.tsx:41,43) — so real heights keep changing.
- Every measured height change fires
totalListHeightChanged={pinBottom} (:402), which calls scrollToIndex({ index: n - 1, align: 'end' }) (:234-239). This is deliberate — the comment at :218-226 documents re-asserting the end on every height change until the user takes control.
- Each pin jumps to an offset computed from the current (still-stabilizing) measurements, so consecutive pins land at different offsets → the user watches the transcript jump/creep repeatedly until hydration settles.
key={agentId} (:390) re-runs this whole sequence on every agent switch.
In short: the bottom is maintained by JS re-scrolling during layout, and on WebKit each correction is visible.
How commercial apps avoid it
- The first visible frame is already the last page — no programmatic scrolling after reveal.
- Anchoring is done by layout/browser scroll-anchoring, not JS: content grows upward above the fold without moving the viewport.
- History is windowed: only the last page (~20–50 messages) renders on open; older pages load on scroll-up.
- Media dimensions are reserved (aspect-ratio placeholders), so hydration doesn't change heights after reveal.
Suggested fixes (ordered by effort/impact)
1. Hide until settled, then reveal instantly (the ChatGPT effect, ~10 lines).
Render Virtuoso with visibility: hidden (layout preserved, so measuring works) until the bottom position has been asserted — e.g. flip a settled state on the first totalListHeightChanged after mount (or after a short rAF chain) — then reveal. The user's first visible frame is the bottom; the re-pin loop still runs but is invisible. No behavior change beyond the visual.
2. Reduce height churn at the source.
- Give markdown images explicit dimensions or an
aspect-ratio placeholder (Markdown.tsx:41,43) so image loads don't reflow rows.
- Pass
defaultItemHeight (≈ average card height) to Virtuoso so the initial offset estimate is close to final — fewer corrections on mount.
3. If the pin loop is kept, make it silent and sparse.
rAF-debounce pinBottom, skip when atBottomStateChange already reports bottom, and stop re-pinning once settled (new events while at bottom are already covered by followOutput; the pin loop's only real job is the hydration window).
4. Structural endgame — windowed history.
Open with tail: 50 and prepend older pages via startReached + firstItemIndex bookkeeping. The events endpoint is currently tail-only with no older-than cursor (comment at :19-25), so this needs a before_seq cursor on the hub — or client-side windowing (fetch 500, feed Virtuoso a growing slice; data is cheap, hydration churn is the cost).
5. Small consistency note.
The insight-mode Virtuoso (:490-497) has no initialTopMostItemIndex and opens at the top; decide its open position deliberately (top is defensible for a turns/errors navigator, but it should be a choice, not a default).
Acceptance criteria
- Opening any agent transcript (including one with 500 events, images, and KaTeX) shows the last page in the first visible frame, with zero visible scroll motion.
- Switching agents never shows intermediate scroll positions.
- New streamed events still auto-scroll when at bottom, and never yank the view when the user has scrolled up (existing
followOutput contract preserved).
When opening an agent transcript, the feed visibly scrolls/jumps to the last message — often several times in the first second — instead of simply opening at the last page the way ChatGPT/Claude conversations do. Traced through the code; the motion is a designed-in re-pin loop, and the commercial-grade fix is to make the first visible frame already be the bottom.
Root cause
Open sequence today (
desktop/src/surfaces/AgentTranscript.tsx):LOAD_TAIL = 500,:25,137), then Virtuoso mounts withinitialTopMostItemIndex={{ index: liveData.length - 1, align: 'end' }}(:396). At mount all row heights are estimates.desktop/src/ui/Markdown.tsx:41,43) — so real heights keep changing.totalListHeightChanged={pinBottom}(:402), which callsscrollToIndex({ index: n - 1, align: 'end' })(:234-239). This is deliberate — the comment at:218-226documents re-asserting the end on every height change until the user takes control.key={agentId}(:390) re-runs this whole sequence on every agent switch.In short: the bottom is maintained by JS re-scrolling during layout, and on WebKit each correction is visible.
How commercial apps avoid it
Suggested fixes (ordered by effort/impact)
1. Hide until settled, then reveal instantly (the ChatGPT effect, ~10 lines).
Render Virtuoso with
visibility: hidden(layout preserved, so measuring works) until the bottom position has been asserted — e.g. flip asettledstate on the firsttotalListHeightChangedafter mount (or after a short rAF chain) — then reveal. The user's first visible frame is the bottom; the re-pin loop still runs but is invisible. No behavior change beyond the visual.2. Reduce height churn at the source.
aspect-ratioplaceholder (Markdown.tsx:41,43) so image loads don't reflow rows.defaultItemHeight(≈ average card height) to Virtuoso so the initial offset estimate is close to final — fewer corrections on mount.3. If the pin loop is kept, make it silent and sparse.
rAF-debounce
pinBottom, skip whenatBottomStateChangealready reports bottom, and stop re-pinning once settled (new events while at bottom are already covered byfollowOutput; the pin loop's only real job is the hydration window).4. Structural endgame — windowed history.
Open with
tail: 50and prepend older pages viastartReached+firstItemIndexbookkeeping. The events endpoint is currently tail-only with no older-than cursor (comment at:19-25), so this needs abefore_seqcursor on the hub — or client-side windowing (fetch 500, feed Virtuoso a growing slice; data is cheap, hydration churn is the cost).5. Small consistency note.
The insight-mode Virtuoso (
:490-497) has noinitialTopMostItemIndexand opens at the top; decide its open position deliberately (top is defensible for a turns/errors navigator, but it should be a choice, not a default).Acceptance criteria
followOutputcontract preserved).