fix(desktop): stop long-session transcript from drifting to old turns (#69019)

content-visibility:auto on turn groups (perf: off-screen turns skip
style/layout/paint) pairs with contain-intrinsic-size:auto, which only
remembers a turn's size after it renders. A turn that finished streaming
near the bottom had its smaller mid-stream size remembered; once it
scrolled off the top edge and got skipped, it collapsed to that stale
height. With overflow-anchor:none the viewport can't self-correct, so the
stick-to-bottom lock drifts and the view creeps up over older turns — the
'long session eventually shows old responses' visual glitch.

Exempt the newest turns (live tail) from virtualization so a turn is only
ever skipped after its layout has settled at its final size (remembered ==
real -> skipping changes no height). Off-screen older turns still skip, so
the dialog/popover whole-document recalc win on long transcripts is kept
(it scales with the hundreds of old turns, not the small tail).
This commit is contained in:
brooklyn! 2026-07-21 20:30:42 -05:00 committed by GitHub
parent 967e078ae4
commit 3de91c6974
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 63 additions and 3 deletions

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { buildGroups, firstVisibleGroupIndex, type MessageGroup } from './list'
import { buildGroups, firstVisibleGroupIndex, isVirtualizedGroup, LIVE_TAIL_GROUPS, type MessageGroup } from './list'
// Signature rows are `${index}:${id}:${role}:${weight}` (see the useAuiState
// selector in list.tsx).
@ -80,3 +80,33 @@ describe('firstVisibleGroupIndex', () => {
expect(firstVisibleGroupIndex([], 60)).toBe(0)
})
})
describe('isVirtualizedGroup', () => {
it('never virtualizes the newest turns (the live tail)', () => {
const count = 20
for (let i = count - LIVE_TAIL_GROUPS; i < count; i++) {
expect(isVirtualizedGroup(i, count)).toBe(false)
}
})
it('virtualizes older turns that sit before the live tail', () => {
const count = 20
expect(isVirtualizedGroup(0, count)).toBe(true)
expect(isVirtualizedGroup(count - LIVE_TAIL_GROUPS - 1, count)).toBe(true)
})
it('keeps every turn rendered when the whole transcript fits in the tail', () => {
const count = LIVE_TAIL_GROUPS
for (let i = 0; i < count; i++) {
expect(isVirtualizedGroup(i, count)).toBe(false)
}
})
it('honors a custom tail size', () => {
expect(isVirtualizedGroup(5, 10, 3)).toBe(true)
expect(isVirtualizedGroup(7, 10, 3)).toBe(false)
})
})

View file

@ -113,6 +113,27 @@ export function firstVisibleGroupIndex(groups: readonly MessageGroup[], budget:
return firstVisible
}
// content-visibility:auto skips off-screen turns for perf, but with
// contain-intrinsic-size:auto the browser only remembers a turn's size AFTER
// it has rendered. A turn that finishes streaming near the bottom may have had
// its (smaller) mid-stream size remembered; when it scrolls just off the top
// edge and gets skipped, it snaps back to that stale height, shifting content
// down. With overflow-anchor:none (the viewport can't self-correct) the
// stick-to-bottom lock drifts and the view creeps up over older turns — the
// "long session eventually shows old responses" glitch.
//
// Keep the newest N turns always-rendered so a turn is only ever virtualized
// once its layout has settled at its final size (remembered == real → skipping
// it changes no height). Off-screen OLDER turns still skip, so the dialog/popover
// recalc win on long transcripts is preserved (that scales with the hundreds of
// old turns, not this small live tail).
export const LIVE_TAIL_GROUPS = 6
/** True when a visible group is old enough to virtualize (outside the live tail). */
export function isVirtualizedGroup(indexInVisible: number, visibleCount: number, liveTail = LIVE_TAIL_GROUPS): boolean {
return indexInVisible < visibleCount - liveTail
}
const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
clampToComposer,
components,
@ -359,7 +380,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
{t.assistant.thread.showEarlier}
</button>
)}
{visibleGroups.map(group => (
{visibleGroups.map((group, indexInVisible) => (
// content-visibility:auto — off-screen turns skip style recalc,
// layout, and paint. On a long transcript this is what keeps
// UNRELATED UI fast: any dialog/popover mount (Radix Presence
@ -370,8 +391,17 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
// real size once rendered), so scrollbar/anchoring stay stable.
// Sticky human bubbles are unaffected — their turn is rendered
// whenever any part of it intersects the viewport.
//
// The live tail (newest turns) is exempt: virtualizing a turn
// whose final size hasn't been remembered yet snaps it to a stale
// height when it scrolls off, drifting stick-to-bottom up over old
// turns. See isVirtualizedGroup.
<div
className="flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap) [contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]"
className={cn(
'flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)',
isVirtualizedGroup(indexInVisible, visibleGroups.length) &&
'[contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]'
)}
key={group.id}
>
<MessageRenderBoundary resetKey={messageSignature}>