fix(desktop): account for transcript payload size

This commit is contained in:
Jason Chai 2026-07-31 17:42:12 +08:00 committed by kshitij
parent 5b5932886c
commit c5829fbb34
2 changed files with 149 additions and 29 deletions

View File

@ -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 })

View File

@ -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<object, number>()
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<object>()
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<ThreadMessageListProps> = ({
}) => {
// 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<ThreadMessageListProps> = ({
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<ThreadMessageListProps> = ({
// 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<ThreadMessageListProps> = ({
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<ThreadMessageListProps> = ({
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),