perf(desktop): budget the transcript live tail in parts, not turns

The content-visibility virtualization from #66470 stopped engaging on agent
sessions. Its live tail — the newest turns kept always-rendered so a turn is
only virtualized once its height has settled — was sized as a raw count of 6
turns, while everything else in this file budgets in rendered PARTS
(RENDER_BUDGET=300, FIRST_PAINT_BUDGET=20).

Those units diverge badly on agent transcripts. A chat turn is 2-6 parts, but a
turn with tool calls is 50-200, so "6 turns" can exempt the entire visible
transcript. Measured on a 5-tile window (7/3/5/3/2 groups per tile): zero
content-visibility containers were active anywhere, and every Radix overlay
open paid the full whole-document style recalc that #66470 exists to avoid
(~610ms of a ~700ms open, in a handful of enormous recalcs rather than any
long task).

Size the tail by parts instead, clamped to [2, 6] turns. The floor keeps the
streaming turn rendered when turns are huge, preserving the anti-drift
guarantee; the ceiling stops a tail of tiny turns from reaching further back
than the old turn-count policy did, so no transcript shape renders more than
before. `liveTailStart` replaces the per-row `isVirtualizedGroup` predicate and
is computed once per render off the weighted groups.

Parts left always-rendered, real transcript shapes:

| shape                        | before | after |
|------------------------------|--------|-------|
| agent tile (7 tool-heavy)    |    690 |   270 |
| agent tile (5 turns)         |    535 |   225 |
| long agent session (40)      |    720 |   240 |
| long chat (40 short turns)   |     24 |    24 |
This commit is contained in:
Brooklyn Nicholson 2026-07-30 02:14:22 -05:00
parent 866c9adae3
commit 975f4ef38d
2 changed files with 137 additions and 30 deletions

View file

@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest'
import { buildGroups, firstVisibleGroupIndex, isVirtualizedGroup, LIVE_TAIL_GROUPS, type MessageGroup } from './list'
import {
buildGroups,
firstVisibleGroupIndex,
LIVE_TAIL_MIN_GROUPS,
LIVE_TAIL_PARTS,
liveTailStart,
type MessageGroup
} from './list'
// Signature rows are `${index}:${id}:${role}:${weight}` (see the useAuiState
// selector in list.tsx).
@ -81,32 +88,79 @@ describe('firstVisibleGroupIndex', () => {
})
})
describe('isVirtualizedGroup', () => {
it('never virtualizes the newest turns (the live tail)', () => {
const count = 20
describe('liveTailStart', () => {
const group = (id: string, weight: number): MessageGroup => ({ id, index: 0, kind: 'standalone', weight })
for (let i = count - LIVE_TAIL_GROUPS; i < count; i++) {
expect(isVirtualizedGroup(i, count)).toBe(false)
}
it('keeps the newest turns rendered until the parts budget is spent', () => {
// 10 turns x 10 parts. A 40-part tail covers the newest 4-5 turns.
const groups = Array.from({ length: 10 }, (_, i) => group(`g${i}`, 10))
const start = liveTailStart(groups)
expect(start).toBeGreaterThan(0)
expect(start).toBeLessThan(groups.length)
// Everything from `start` onward is the live tail...
const tailParts = groups.slice(start).reduce((sum, g) => sum + g.weight, 0)
expect(tailParts).toBeGreaterThan(LIVE_TAIL_PARTS)
// ...and dropping its oldest member puts it back under budget, i.e. the
// tail is minimal rather than sprawling.
const withoutOldest = groups.slice(start + 1).reduce((sum, g) => sum + g.weight, 0)
expect(withoutOldest).toBeLessThanOrEqual(LIVE_TAIL_PARTS)
})
it('virtualizes older turns that sit before the live tail', () => {
const count = 20
it('virtualizes the old bulk of a long agent transcript', () => {
// The regression this guards: heavy tool turns. A turn-count tail (6) left
// NOTHING virtualized on transcripts like this, so every Radix overlay open
// paid a whole-document style recalc.
const groups = Array.from({ length: 40 }, (_, i) => group(`g${i}`, 120))
expect(isVirtualizedGroup(0, count)).toBe(true)
expect(isVirtualizedGroup(count - LIVE_TAIL_GROUPS - 1, count)).toBe(true)
// Only the min-group floor stays rendered; the other 38 turns skip.
expect(liveTailStart(groups)).toBe(groups.length - LIVE_TAIL_MIN_GROUPS)
})
it('never virtualizes below the min-group floor, however heavy the turns', () => {
const groups = Array.from({ length: 5 }, (_, i) => group(`g${i}`, 10_000))
expect(liveTailStart(groups)).toBe(groups.length - LIVE_TAIL_MIN_GROUPS)
})
it('keeps every turn rendered when the whole transcript fits in the tail', () => {
const count = LIVE_TAIL_GROUPS
const groups = [group('a', 5), group('b', 5), group('c', 5)]
for (let i = 0; i < count; i++) {
expect(isVirtualizedGroup(i, count)).toBe(false)
expect(liveTailStart(groups)).toBe(0)
})
it('handles an empty transcript', () => {
expect(liveTailStart([])).toBe(0)
})
it('honors a custom budget', () => {
const groups = Array.from({ length: 10 }, (_, i) => group(`g${i}`, 1))
// A 3-part budget would keep 4 turns, but the max-groups ceiling is not hit
// here, so the parts budget wins.
expect(liveTailStart(groups, 3)).toBe(6)
})
it('never renders more than the old turn-count tail did, on any shape', () => {
// Guards the one way a parts budget can regress: a long transcript of tiny
// turns, where walking back 40 parts reaches further than 6 turns would.
const shapes = [
Array.from({ length: 40 }, () => 4), // long chat, tiny turns
Array.from({ length: 40 }, () => 1), // pathological: 1-part turns
Array.from({ length: 12 }, () => 6),
[80, 120, 60, 150, 90, 200, 70], // real agent tile
[30, 45]
]
for (const weights of shapes) {
const groups = weights.map((weight, i) => group(`g${i}`, weight))
const rendered = (start: number) => weights.slice(start).reduce((a, b) => a + b, 0)
const oldStart = Math.max(0, groups.length - 6)
expect(rendered(liveTailStart(groups))).toBeLessThanOrEqual(rendered(oldStart))
}
})
it('honors a custom tail size', () => {
expect(isVirtualizedGroup(5, 10, 3)).toBe(true)
expect(isVirtualizedGroup(7, 10, 3)).toBe(false)
})
})

View file

@ -130,16 +130,63 @@ export function firstVisibleGroupIndex(groups: readonly MessageGroup[], budget:
// 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
// Keep the newest 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
// recalc win on long transcripts is preserved.
//
// The tail is budgeted in PARTS, not turns, because that is what the cost
// actually scales with — the same currency as RENDER_BUDGET / FIRST_PAINT_BUDGET.
// A turn-count tail silently defeats itself on agent transcripts: one tool-heavy
// turn is 50-200 parts, so a 6-TURN tail exempted the entire visible transcript
// and nothing virtualized at all. Measured on a 5-tile window (7/3/5/3/2 groups
// per tile): zero content-visibility containers were active, and every Radix
// overlay open paid the full ~610ms whole-document recalc that #66470 fixed.
//
// 40 parts ≈ the 1-2 turns a viewport shows after scroll-to-bottom (the same
// reasoning as FIRST_PAINT_BUDGET=20, doubled so a turn that grows mid-stream
// doesn't fall out of the tail as it settles).
export const LIVE_TAIL_PARTS = 40
// Floor: always exempt at least this many turns regardless of weight, so a
// transcript of very heavy turns still keeps the streaming one unvirtualized.
export const LIVE_TAIL_MIN_GROUPS = 2
// Ceiling: never exempt more than this many turns, however light they are. On a
// long transcript of tiny turns a parts-only budget would walk back further
// than the old turn-count tail did and virtualize LESS — this keeps the new
// policy a strict improvement on every shape.
export const LIVE_TAIL_MAX_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
/**
* Index of the newest group that still virtualizes everything at or after it
* is the live tail and stays rendered. Walks newest-first accumulating parts,
* so the tail covers a viewport's worth of content rather than a fixed number
* of turns, clamped to [MIN, MAX] turns. Computed once per render, not per row.
*/
export function liveTailStart(
groups: readonly MessageGroup[],
tailParts = LIVE_TAIL_PARTS,
minGroups = LIVE_TAIL_MIN_GROUPS,
maxGroups = LIVE_TAIL_MAX_GROUPS
): number {
let parts = 0
let start = groups.length
for (let i = groups.length - 1; i >= 0; i--) {
parts += groups[i]?.weight ?? 1
start = i
if (parts > tailParts) {
break
}
}
// Clamp the tail to [minGroups, maxGroups] turns: the floor keeps the live
// turn rendered when turns are huge, the ceiling stops a tail of tiny turns
// from sprawling past what the old turn-count policy rendered.
const floor = Math.max(0, groups.length - minGroups)
const ceiling = Math.max(0, groups.length - maxGroups)
return Math.min(floor, Math.max(ceiling, start))
}
const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
@ -278,6 +325,13 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
const hiddenCount = firstVisibleGroupIndex(weightedGroups, renderBudget)
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
// Where the always-rendered live tail begins. Derived from the WEIGHTED
// groups (parts, not turns) so the tail is a viewport's worth of content —
// see liveTailStart. Computed once here rather than per row.
const tailStart = useMemo(
() => liveTailStart(hiddenCount > 0 ? weightedGroups.slice(hiddenCount) : weightedGroups),
[weightedGroups, hiddenCount]
)
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
// hide the titlebar tool cluster + session header, but the OS traffic lights
// still sit in the top-left, so reserve the titlebar gap above the transcript.
@ -436,12 +490,11 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
// 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.
// turns. See liveTailStart.
<div
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]'
indexInVisible < tailStart && '[contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]'
)}
key={group.id}
>
@ -461,7 +514,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
</MessageRenderBoundary>
</div>
)),
[visibleGroups, components, structuralSignature]
[visibleGroups, components, structuralSignature, tailStart]
)
return (