Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion apps/desktop/src/components/assistant-ui/thread/list.test.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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)
})
})
34 changes: 32 additions & 2 deletions apps/desktop/src/components/assistant-ui/thread/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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}>
Expand Down
Loading