From 3de91c69743d1fafcf81e0f69db5dafd40c6205f Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Tue, 21 Jul 2026 20:30:42 -0500 Subject: [PATCH] fix(desktop): stop long-session transcript from drifting to old turns (#69019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../assistant-ui/thread/list.test.ts | 32 ++++++++++++++++- .../components/assistant-ui/thread/list.tsx | 34 +++++++++++++++++-- 2 files changed, 63 insertions(+), 3 deletions(-) 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.