feat(desktop): stop hiding a session behind Show earlier
On real sessions the button showed up two or three turns from the bottom, over a screen and a half of transcript that had barely painted anything. The budget now spends paint weight, which is what the DOM actually mounts, and 600 units of it — 10-20 agentic turns measured, where a tool-heavy turn prices at 30-90 and a plain exchange at 5-10. A floor of 8 turns covers the session of enormous turns that a weight-only cut still truncates hard; it applies to a real page only, so the small first-paint commit stays small and the backfill a frame later fills the rest. Measured on four stored sessions at the same budget: one went from 3 turns visible to 12, another from 3 to 4, two unchanged. The store window still caps what the DOM can reach at all.
This commit is contained in:
parent
31459ef0c9
commit
75717d29eb
|
|
@ -1,7 +1,5 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { messageRenderWeight, RENDER_WEIGHT_CHARS } from '@/lib/render-weight'
|
||||
|
||||
import {
|
||||
buildGroups,
|
||||
firstVisibleGroupIndex,
|
||||
|
|
@ -136,49 +134,19 @@ describe('firstVisibleGroupIndex', () => {
|
|||
it('returns groups.length for an empty list', () => {
|
||||
expect(firstVisibleGroupIndex([], 60)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
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) }]
|
||||
it('keeps a floor of turns visible however heavy they are', () => {
|
||||
// Without the floor a session of enormous turns puts "Show earlier" two
|
||||
// turns from the bottom, which reads as broken rather than as paging.
|
||||
const groups = Array.from({ length: 20 }, (_, i) => group(`g${i}`, 5_000))
|
||||
|
||||
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)
|
||||
expect(firstVisibleGroupIndex(groups, 600, 8)).toBe(groups.length - 8)
|
||||
})
|
||||
|
||||
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) }
|
||||
}
|
||||
]
|
||||
it('does not force the floor to hide turns the budget already showed', () => {
|
||||
const groups = Array.from({ length: 20 }, (_, i) => group(`g${i}`, 1))
|
||||
|
||||
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)
|
||||
expect(firstVisibleGroupIndex(groups, 600, 8)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
import { type GetTargetScrollTop, useStickToBottom } from 'use-stick-to-bottom'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { messageRenderWeight } from '@/lib/render-weight'
|
||||
import { messagePaintWeight } from '@/lib/render-weight'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
onScrollToBottomRequest,
|
||||
|
|
@ -37,19 +37,37 @@ export type MessageGroup = { id: string; weight: number } & (
|
|||
{ index: number; kind: 'standalone' } | { indices: number[]; kind: 'turn' }
|
||||
)
|
||||
|
||||
// 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.
|
||||
// DOM is bounded by a render-cost budget, not a message/turn count. The
|
||||
// currency is `messagePaintWeight`: what a turn actually MOUNTS, which is what
|
||||
// the grouping decides rather than what the payload weighs. A settled run of
|
||||
// twelve reads is one grey summary line, a thought is one collapsed
|
||||
// disclosure, a hoisted `todo` is nothing — while a diff, an image card or a
|
||||
// wall of markdown really does build DOM and is charged for it.
|
||||
//
|
||||
// Pricing by payload instead had the budget counting work that never mounts:
|
||||
// one tool-heavy turn measured 84-281 units of tool JSON that painted as a
|
||||
// dozen one-line summaries, so a session spent the whole page in two or three
|
||||
// turns and offered "Show earlier" over a screen and a half of transcript.
|
||||
//
|
||||
// "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
|
||||
//
|
||||
// 600 units ≈ 10-20 agentic turns on measured real sessions (a tool-heavy turn
|
||||
// prices at 30-90, a plain exchange at 5-10), and a whole session of ordinary
|
||||
// work now fits one page instead of paging three times to reach its start.
|
||||
// What the DOM can hold is bounded above by the store window regardless
|
||||
// (TRANSCRIPT_WINDOW_BUDGET), so this cannot admit more than one window's
|
||||
// content.
|
||||
const RENDER_BUDGET = 600
|
||||
// Never offer "Show earlier" over fewer turns than this, however heavy they
|
||||
// are. A weight-only cut on a session of enormous turns put the button two
|
||||
// turns from the bottom, where it reads as broken rather than as paging — the
|
||||
// user has not been given enough transcript to have gone looking for more. The
|
||||
// store window caps what the DOM can reach at all, so a floor here stays
|
||||
// bounded.
|
||||
const MIN_VISIBLE_GROUPS = 8
|
||||
// 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
|
||||
|
|
@ -125,9 +143,13 @@ export function buildGroups(signature: string): MessageGroup[] {
|
|||
}
|
||||
|
||||
// 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 {
|
||||
// everything before the first kept turn is hidden. `minVisible` turns are kept
|
||||
// regardless of weight. Returns the index of that first visible group.
|
||||
export function firstVisibleGroupIndex(
|
||||
groups: readonly MessageGroup[],
|
||||
budget: number,
|
||||
minVisible = 0
|
||||
): number {
|
||||
let firstVisible = groups.length
|
||||
|
||||
for (let i = groups.length - 1, weight = 0; i >= 0; i--) {
|
||||
|
|
@ -139,7 +161,7 @@ export function firstVisibleGroupIndex(groups: readonly MessageGroup[], budget:
|
|||
}
|
||||
}
|
||||
|
||||
return firstVisible
|
||||
return Math.min(firstVisible, Math.max(0, groups.length - minVisible))
|
||||
}
|
||||
|
||||
// content-visibility:auto skips off-screen turns for perf, but with
|
||||
|
|
@ -231,7 +253,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||
)
|
||||
|
||||
const weightSignature = useAuiState(s =>
|
||||
s.thread.messages.map(message => messageRenderWeight(message.content)).join(',')
|
||||
s.thread.messages.map(message => messagePaintWeight(message.content)).join(',')
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
|
@ -337,7 +359,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||
|
||||
// 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.
|
||||
// sum — not the row JSX. Settled content hits messagePaintWeight's WeakMap.
|
||||
const weightedGroups = useMemo(() => {
|
||||
const weights = weightSignature.split(',').map(w => Number(w) || 1)
|
||||
|
||||
|
|
@ -350,7 +372,15 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||
}))
|
||||
}, [groups, weightSignature])
|
||||
|
||||
const hiddenCount = firstVisibleGroupIndex(weightedGroups, renderBudget)
|
||||
// The turn floor applies to a real page only. During the first-paint budget
|
||||
// the point is a small synchronous commit; forcing 8 turns into it would put
|
||||
// back exactly the freeze FIRST_PAINT_BUDGET exists to avoid, and the rAF
|
||||
// backfill a frame later fills them in anyway.
|
||||
const hiddenCount = firstVisibleGroupIndex(
|
||||
weightedGroups,
|
||||
renderBudget,
|
||||
renderBudget >= RENDER_BUDGET ? MIN_VISIBLE_GROUPS : 0
|
||||
)
|
||||
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
|
||||
|
||||
// Where the always-rendered live tail begins. Derived from the WEIGHTED
|
||||
|
|
|
|||
Loading…
Reference in New Issue