From 75717d29eb031a2a42d1879c8ccdeb3519590bae Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 6 Aug 2026 21:20:57 -0500 Subject: [PATCH] feat(desktop): stop hiding a session behind Show earlier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../assistant-ui/thread/list.test.ts | 48 +++----------- .../components/assistant-ui/thread/list.tsx | 62 ++++++++++++++----- 2 files changed, 54 insertions(+), 56 deletions(-) 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 a44314d154ffc..f2a3d66b4b5ee 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.test.ts +++ b/apps/desktop/src/components/assistant-ui/thread/list.test.ts @@ -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) }) }) diff --git a/apps/desktop/src/components/assistant-ui/thread/list.tsx b/apps/desktop/src/components/assistant-ui/thread/list.tsx index 304b3eb3ec017..691f8f9323a24 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/list.tsx @@ -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 = ({ ) 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 = ({ // 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 = ({ })) }, [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