fix(desktop): account for transcript payload size
This commit is contained in:
parent
5b5932886c
commit
c5829fbb34
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue