From 975f4ef38d2ff671172edd2078b75f05bf3b87d0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 02:14:22 -0500 Subject: [PATCH] perf(desktop): budget the transcript live tail in parts, not turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 | --- .../assistant-ui/thread/list.test.ts | 92 +++++++++++++++---- .../components/assistant-ui/thread/list.tsx | 75 ++++++++++++--- 2 files changed, 137 insertions(+), 30 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 e053af6f5ba..a09d57deba4 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,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) - }) }) diff --git a/apps/desktop/src/components/assistant-ui/thread/list.tsx b/apps/desktop/src/components/assistant-ui/thread/list.tsx index 3afbc324c4f..ba4b7964152 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/list.tsx @@ -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 = ({ @@ -278,6 +325,13 @@ const ThreadMessageListInner: FC = ({ 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 = ({ // 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.
@@ -461,7 +514,7 @@ const ThreadMessageListInner: FC = ({
)), - [visibleGroups, components, structuralSignature] + [visibleGroups, components, structuralSignature, tailStart] ) return (