diff --git a/apps/desktop/src/components/assistant-ui/thread/list.test.ts b/apps/desktop/src/components/assistant-ui/thread/list.test.ts index b25725ecb6f0..e053af6f5ba9 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.test.ts +++ b/apps/desktop/src/components/assistant-ui/thread/list.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/list.tsx b/apps/desktop/src/components/assistant-ui/thread/list.tsx index d549229660a6..e80938cf4d0e 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/list.tsx @@ -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 = ({ clampToComposer, components, @@ -359,7 +380,7 @@ const ThreadMessageListInner: FC = ({ {t.assistant.thread.showEarlier} )} - {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 = ({ // 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.