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:
Brooklyn Nicholson 2026-08-06 21:20:57 -05:00
parent 31459ef0c9
commit 75717d29eb
2 changed files with 54 additions and 56 deletions

View File

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

View File

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