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 a09d57deba4ed..413aab88650f0 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.test.ts +++ b/apps/desktop/src/components/assistant-ui/thread/list.test.ts @@ -6,7 +6,9 @@ import { LIVE_TAIL_MIN_GROUPS, LIVE_TAIL_PARTS, liveTailStart, - type MessageGroup + type MessageGroup, + messageRenderWeight, + RENDER_WEIGHT_CHARS } from './list' // Signature rows are `${index}:${id}:${role}:${weight}` (see the useAuiState @@ -88,6 +90,50 @@ describe('firstVisibleGroupIndex', () => { }) }) +describe('messageRenderWeight', () => { + it('charges large text and tool results by character cost, not only part count', () => { + const text = [{ type: 'text', text: 'x'.repeat(RENDER_WEIGHT_CHARS * 3) }] + + const tool = [ + { + type: 'tool-call', + toolName: 'skill_view', + args: { name: 'hermes-agent' }, + result: { content: 'x'.repeat(RENDER_WEIGHT_CHARS * 100) } + } + ] + + expect(messageRenderWeight(text)).toBe(4) + expect(messageRenderWeight(tool)).toBeGreaterThanOrEqual(101) + }) + + it('makes repeated 51KB tool outputs exceed the normal transcript page', () => { + const toolOutput = () => [ + { + type: 'tool-call', + toolName: 'skill_view', + result: { content: 'x'.repeat(51_236) } + } + ] + + const groups = Array.from({ length: 5 }, (_, index) => ({ + id: `tool-${index}`, + index, + kind: 'standalone' as const, + weight: messageRenderWeight(toolOutput()) + })) + + expect(firstVisibleGroupIndex(groups, 300)).toBeGreaterThan(0) + }) + + it('handles circular tool payloads without recursing forever', () => { + const result: { content: string; self?: unknown } = { content: 'ok' } + result.self = result + + expect(messageRenderWeight([{ type: 'tool-call', result }])).toBe(2) + }) +}) + describe('liveTailStart', () => { const group = (id: string, weight: number): MessageGroup => ({ id, index: 0, kind: 'standalone', weight }) diff --git a/apps/desktop/src/components/assistant-ui/thread/list.tsx b/apps/desktop/src/components/assistant-ui/thread/list.tsx index dc5796294ec28..faa07248832b5 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/list.tsx @@ -34,27 +34,98 @@ export type MessageGroup = { id: string; weight: number } & ( { index: number; kind: 'standalone' } | { indices: number[]; kind: 'turn' } ) -// DOM is bounded by a rendered-PART budget, not a message/turn count: a single -// assistant message folds every tool call into a part, so heavy sessions are -// ~40 turns / ~100 messages but ~1000 parts — and parts are what drive node -// count. "Show earlier" prepends another page; whole turns stay intact so the -// sticky human bubble never loses its turn. This is the long-session perf lever -// WITHOUT a virtualizer — pure rendering, never touches scrollTop, so it can't -// fight use-stick-to-bottom (the single scroll owner). +// DOM is bounded by a render-cost budget, not a message/turn count. Every part +// costs one unit, and large strings add another unit per 512 characters. Parts +// approximate component/node count; characters approximate markdown parsing, +// text-node allocation, and tool-result formatting. Counting only parts badly +// underpriced a 51KB tool result as "1", so a handful of huge results let a +// 600KB transcript through the old 300-part cap and could drive Chromium's renderer +// into a GC crash. +// +// "Show earlier" prepends another page; whole turns stay intact so the sticky +// human bubble never loses its turn. This is the long-session perf lever WITHOUT +// a virtualizer — pure rendering, never touches scrollTop, so it can't fight +// use-stick-to-bottom (the single scroll owner). const RENDER_BUDGET = 300 +export const RENDER_WEIGHT_CHARS = 512 +const MAX_MEASURED_MESSAGE_CHARS = RENDER_BUDGET * RENDER_WEIGHT_CHARS // On session switch, paint a small budget first (enough for the bottom turn(s) // the user actually sees after scroll-to-bottom), then bump to the full budget // in a requestAnimationFrame — defers the heavy markdown+syntax-highlight render // past the initial commit, so the switch feels instant. // // 20, down from 60: the first-paint commit is synchronous and uninterruptible, -// and at 60 parts it measured 627ms on a real session (LoAF: block=575ms, no +// and at 60 cost units it measured 627ms on a real session (LoAF: block=575ms, no // attributed script — pure commit). A viewport after scroll-to-bottom shows -// 1-2 turns ≈ 10-20 parts; the transition backfill below fills the rest +// 1-2 normal turns ≈ 10-20 units; the transition backfill below fills the rest // interruptibly, so the only thing a smaller budget changes is how much work // blocks the click-to-paint path. const FIRST_PAINT_BUDGET = 20 +const contentWeightCache = new WeakMap() +const NON_RENDERED_CONTENT_FIELDS = new Set(['id', 'role', 'toolCallId', 'toolName', 'type']) + +/** + * Estimate the synchronous renderer cost of one assistant-ui message. + * + * The traversal is capped once a single message has enough text to consume a + * complete render page. Going further cannot affect which whole turn crosses + * the budget, and avoiding an unbounded walk matters for deeply nested tool + * payloads. A WeakMap keeps settled history O(message count) on later store + * updates; assistant-ui publishes a new content array when a streaming message + * changes, so the live tail still receives a fresh weight. + */ +export function messageRenderWeight(content: unknown): number { + if (!Array.isArray(content)) { + return 1 + } + + const cached = contentWeightCache.get(content) + + if (cached !== undefined) { + return cached + } + + const seen = new WeakSet() + const pending: unknown[] = [...content] + let characters = 0 + + while (pending.length > 0 && characters < MAX_MEASURED_MESSAGE_CHARS) { + const value = pending.pop() + + if (typeof value === 'string') { + characters += Math.min(value.length, MAX_MEASURED_MESSAGE_CHARS - characters) + + continue + } + + if (!value || typeof value !== 'object' || seen.has(value)) { + continue + } + + seen.add(value) + + if (Array.isArray(value)) { + for (const nested of value) { + pending.push(nested) + } + + continue + } + + for (const [key, nested] of Object.entries(value)) { + if (!NON_RENDERED_CONTENT_FIELDS.has(key)) { + pending.push(nested) + } + } + } + + const weight = Math.max(1, content.length) + Math.ceil(characters / RENDER_WEIGHT_CHARS) + contentWeightCache.set(content, weight) + + return weight +} + interface ThreadMessageListProps { clampToComposer: boolean components: ThreadMessageComponents @@ -102,7 +173,7 @@ export function buildGroups(signature: string): MessageGroup[] { return groups } -// Walk turns newest-first, summing their part weights until the budget is met; +// Walk turns newest-first, summing their render weights until the budget is met; // everything before the first kept turn is hidden. Returns the index of that // first visible group. export function firstVisibleGroupIndex(groups: readonly MessageGroup[], budget: number): number { @@ -134,15 +205,16 @@ export function firstVisibleGroupIndex(groups: readonly MessageGroup[], budget: // it changes no height). Off-screen OLDER turns still skip, so the dialog/popover // 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. +// The tail is budgeted in render-cost units, 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 +// turn is 50-200 units, 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 +// 40 units ≈ 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 @@ -150,31 +222,31 @@ export const LIVE_TAIL_PARTS = 40 // 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 +// long transcript of tiny turns a weight-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 /** * 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, + * is the live tail and stays rendered. Walks newest-first accumulating weight, * 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, + tailWeight = LIVE_TAIL_PARTS, minGroups = LIVE_TAIL_MIN_GROUPS, maxGroups = LIVE_TAIL_MAX_GROUPS ): number { - let parts = 0 + let weight = 0 let start = groups.length for (let i = groups.length - 1; i >= 0; i--) { - parts += groups[i]?.weight ?? 1 + weight += groups[i]?.weight ?? 1 start = i - if (parts > tailParts) { + if (weight > tailWeight) { break } } @@ -197,8 +269,8 @@ const ThreadMessageListInner: FC = ({ }) => { // TWO signatures, deliberately split. The STRUCTURAL one (ids/roles/count) // changes only when messages are added/removed/swapped — it keys the error - // boundaries and the row identity. The WEIGHT one (per-message part counts) - // ticks while a streaming turn appends parts — it feeds only the render + // boundaries and the row identity. The WEIGHT one (parts + character cost) + // ticks while a streaming turn appends content — it feeds only the render // budget. Folding weights into the structural key handed every boundary a // new resetKey per appended part, which reconciled every turn's subtree on // every tick (measured: 540 wasted Block renders per explain() sample with @@ -207,7 +279,9 @@ const ThreadMessageListInner: FC = ({ s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n') ) - const weightSignature = useAuiState(s => s.thread.messages.map(message => message.content?.length ?? 1).join(',')) + const weightSignature = useAuiState(s => + s.thread.messages.map(message => messageRenderWeight(message.content)).join(',') + ) const { t } = useI18n() // Row structure is memoized on the STRUCTURAL signature only, so streaming @@ -230,7 +304,7 @@ const ThreadMessageListInner: FC = ({ // Cut the budget during RENDER, not in the post-commit layout effect. An // effect-time cut is too late: React would first build the whole tree with - // the full budget (up to 300 parts of markdown + syntax highlighting), + // the full budget (up to 300 cost units of markdown + syntax highlighting), // commit it, and only then re-render at the small budget. The render-phase // state adjustment restarts this component immediately — before any child // renders — so the heavy commit never happens. @@ -307,9 +381,9 @@ const ThreadMessageListInner: FC = ({ return () => cancelAnimationFrame(rafId) }, [anchorBeforePrepend, renderBudget]) - // Weights (per-message part counts) fold into the BUDGET only. Group - // identity stays structural, so a streaming append re-runs this cheap sum — - // not the row JSX. Weighted the same way the old combined signature was. + // Weights (part count + visible character cost) fold into the BUDGET only. + // Group identity stays structural, so a streaming append re-runs this cheap + // sum — not the row JSX. Settled content hits messageRenderWeight's WeakMap. const weightedGroups = useMemo(() => { const weights = weightSignature.split(',').map(w => Number(w) || 1) @@ -326,7 +400,7 @@ const ThreadMessageListInner: FC = ({ 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 — + // groups (render cost, 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),