mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
perf(desktop): budget the transcript live tail in parts, not turns
The content-visibility virtualization from #66470 stopped engaging on agent sessions. Its live tail — the newest turns kept always-rendered so a turn is only virtualized once its height has settled — was sized as a raw count of 6 turns, while everything else in this file budgets in rendered PARTS (RENDER_BUDGET=300, FIRST_PAINT_BUDGET=20). Those units diverge badly on agent transcripts. A chat turn is 2-6 parts, but a turn with tool calls is 50-200, so "6 turns" can exempt the entire visible transcript. Measured on a 5-tile window (7/3/5/3/2 groups per tile): zero content-visibility containers were active anywhere, and every Radix overlay open paid the full whole-document style recalc that #66470 exists to avoid (~610ms of a ~700ms open, in a handful of enormous recalcs rather than any long task). Size the tail by parts instead, clamped to [2, 6] turns. The floor keeps the streaming turn rendered when turns are huge, preserving the anti-drift guarantee; the ceiling stops a tail of tiny turns from reaching further back than the old turn-count policy did, so no transcript shape renders more than before. `liveTailStart` replaces the per-row `isVirtualizedGroup` predicate and is computed once per render off the weighted groups. Parts left always-rendered, real transcript shapes: | shape | before | after | |------------------------------|--------|-------| | agent tile (7 tool-heavy) | 690 | 270 | | agent tile (5 turns) | 535 | 225 | | long agent session (40) | 720 | 240 | | long chat (40 short turns) | 24 | 24 |
This commit is contained in:
parent
866c9adae3
commit
975f4ef38d
2 changed files with 137 additions and 30 deletions
|
|
@ -1,6 +1,13 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildGroups, firstVisibleGroupIndex, isVirtualizedGroup, LIVE_TAIL_GROUPS, type MessageGroup } from './list'
|
||||
import {
|
||||
buildGroups,
|
||||
firstVisibleGroupIndex,
|
||||
LIVE_TAIL_MIN_GROUPS,
|
||||
LIVE_TAIL_PARTS,
|
||||
liveTailStart,
|
||||
type MessageGroup
|
||||
} from './list'
|
||||
|
||||
// Signature rows are `${index}:${id}:${role}:${weight}` (see the useAuiState
|
||||
// selector in list.tsx).
|
||||
|
|
@ -81,32 +88,79 @@ describe('firstVisibleGroupIndex', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('isVirtualizedGroup', () => {
|
||||
it('never virtualizes the newest turns (the live tail)', () => {
|
||||
const count = 20
|
||||
describe('liveTailStart', () => {
|
||||
const group = (id: string, weight: number): MessageGroup => ({ id, index: 0, kind: 'standalone', weight })
|
||||
|
||||
for (let i = count - LIVE_TAIL_GROUPS; i < count; i++) {
|
||||
expect(isVirtualizedGroup(i, count)).toBe(false)
|
||||
}
|
||||
it('keeps the newest turns rendered until the parts budget is spent', () => {
|
||||
// 10 turns x 10 parts. A 40-part tail covers the newest 4-5 turns.
|
||||
const groups = Array.from({ length: 10 }, (_, i) => group(`g${i}`, 10))
|
||||
const start = liveTailStart(groups)
|
||||
|
||||
expect(start).toBeGreaterThan(0)
|
||||
expect(start).toBeLessThan(groups.length)
|
||||
|
||||
// Everything from `start` onward is the live tail...
|
||||
const tailParts = groups.slice(start).reduce((sum, g) => sum + g.weight, 0)
|
||||
expect(tailParts).toBeGreaterThan(LIVE_TAIL_PARTS)
|
||||
|
||||
// ...and dropping its oldest member puts it back under budget, i.e. the
|
||||
// tail is minimal rather than sprawling.
|
||||
const withoutOldest = groups.slice(start + 1).reduce((sum, g) => sum + g.weight, 0)
|
||||
expect(withoutOldest).toBeLessThanOrEqual(LIVE_TAIL_PARTS)
|
||||
})
|
||||
|
||||
it('virtualizes older turns that sit before the live tail', () => {
|
||||
const count = 20
|
||||
it('virtualizes the old bulk of a long agent transcript', () => {
|
||||
// The regression this guards: heavy tool turns. A turn-count tail (6) left
|
||||
// NOTHING virtualized on transcripts like this, so every Radix overlay open
|
||||
// paid a whole-document style recalc.
|
||||
const groups = Array.from({ length: 40 }, (_, i) => group(`g${i}`, 120))
|
||||
|
||||
expect(isVirtualizedGroup(0, count)).toBe(true)
|
||||
expect(isVirtualizedGroup(count - LIVE_TAIL_GROUPS - 1, count)).toBe(true)
|
||||
// Only the min-group floor stays rendered; the other 38 turns skip.
|
||||
expect(liveTailStart(groups)).toBe(groups.length - LIVE_TAIL_MIN_GROUPS)
|
||||
})
|
||||
|
||||
it('never virtualizes below the min-group floor, however heavy the turns', () => {
|
||||
const groups = Array.from({ length: 5 }, (_, i) => group(`g${i}`, 10_000))
|
||||
|
||||
expect(liveTailStart(groups)).toBe(groups.length - LIVE_TAIL_MIN_GROUPS)
|
||||
})
|
||||
|
||||
it('keeps every turn rendered when the whole transcript fits in the tail', () => {
|
||||
const count = LIVE_TAIL_GROUPS
|
||||
const groups = [group('a', 5), group('b', 5), group('c', 5)]
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
expect(isVirtualizedGroup(i, count)).toBe(false)
|
||||
expect(liveTailStart(groups)).toBe(0)
|
||||
})
|
||||
|
||||
it('handles an empty transcript', () => {
|
||||
expect(liveTailStart([])).toBe(0)
|
||||
})
|
||||
|
||||
it('honors a custom budget', () => {
|
||||
const groups = Array.from({ length: 10 }, (_, i) => group(`g${i}`, 1))
|
||||
|
||||
// A 3-part budget would keep 4 turns, but the max-groups ceiling is not hit
|
||||
// here, so the parts budget wins.
|
||||
expect(liveTailStart(groups, 3)).toBe(6)
|
||||
})
|
||||
|
||||
it('never renders more than the old turn-count tail did, on any shape', () => {
|
||||
// Guards the one way a parts budget can regress: a long transcript of tiny
|
||||
// turns, where walking back 40 parts reaches further than 6 turns would.
|
||||
const shapes = [
|
||||
Array.from({ length: 40 }, () => 4), // long chat, tiny turns
|
||||
Array.from({ length: 40 }, () => 1), // pathological: 1-part turns
|
||||
Array.from({ length: 12 }, () => 6),
|
||||
[80, 120, 60, 150, 90, 200, 70], // real agent tile
|
||||
[30, 45]
|
||||
]
|
||||
|
||||
for (const weights of shapes) {
|
||||
const groups = weights.map((weight, i) => group(`g${i}`, weight))
|
||||
const rendered = (start: number) => weights.slice(start).reduce((a, b) => a + b, 0)
|
||||
|
||||
const oldStart = Math.max(0, groups.length - 6)
|
||||
|
||||
expect(rendered(liveTailStart(groups))).toBeLessThanOrEqual(rendered(oldStart))
|
||||
}
|
||||
})
|
||||
|
||||
it('honors a custom tail size', () => {
|
||||
expect(isVirtualizedGroup(5, 10, 3)).toBe(true)
|
||||
expect(isVirtualizedGroup(7, 10, 3)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -130,16 +130,63 @@ export function firstVisibleGroupIndex(groups: readonly MessageGroup[], budget:
|
|||
// stick-to-bottom lock drifts and the view creeps up over older turns — the
|
||||
// "long session eventually shows old responses" glitch.
|
||||
//
|
||||
// Keep the newest N turns always-rendered so a turn is only ever virtualized
|
||||
// Keep the newest turns always-rendered so a turn is only ever virtualized
|
||||
// once its layout has settled at its final size (remembered == real → skipping
|
||||
// it changes no height). Off-screen OLDER turns still skip, so the dialog/popover
|
||||
// recalc win on long transcripts is preserved (that scales with the hundreds of
|
||||
// old turns, not this small live tail).
|
||||
export const LIVE_TAIL_GROUPS = 6
|
||||
// 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.
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
// Floor: always exempt at least this many turns regardless of weight, so a
|
||||
// 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
|
||||
// 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
|
||||
|
||||
/** True when a visible group is old enough to virtualize (outside the live tail). */
|
||||
export function isVirtualizedGroup(indexInVisible: number, visibleCount: number, liveTail = LIVE_TAIL_GROUPS): boolean {
|
||||
return indexInVisible < visibleCount - liveTail
|
||||
/**
|
||||
* 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,
|
||||
* 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,
|
||||
minGroups = LIVE_TAIL_MIN_GROUPS,
|
||||
maxGroups = LIVE_TAIL_MAX_GROUPS
|
||||
): number {
|
||||
let parts = 0
|
||||
let start = groups.length
|
||||
|
||||
for (let i = groups.length - 1; i >= 0; i--) {
|
||||
parts += groups[i]?.weight ?? 1
|
||||
start = i
|
||||
|
||||
if (parts > tailParts) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp the tail to [minGroups, maxGroups] turns: the floor keeps the live
|
||||
// turn rendered when turns are huge, the ceiling stops a tail of tiny turns
|
||||
// from sprawling past what the old turn-count policy rendered.
|
||||
const floor = Math.max(0, groups.length - minGroups)
|
||||
const ceiling = Math.max(0, groups.length - maxGroups)
|
||||
|
||||
return Math.min(floor, Math.max(ceiling, start))
|
||||
}
|
||||
|
||||
const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
||||
|
|
@ -278,6 +325,13 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||
|
||||
const hiddenCount = firstVisibleGroupIndex(weightedGroups, renderBudget)
|
||||
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 —
|
||||
// see liveTailStart. Computed once here rather than per row.
|
||||
const tailStart = useMemo(
|
||||
() => liveTailStart(hiddenCount > 0 ? weightedGroups.slice(hiddenCount) : weightedGroups),
|
||||
[weightedGroups, hiddenCount]
|
||||
)
|
||||
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
|
||||
// hide the titlebar tool cluster + session header, but the OS traffic lights
|
||||
// still sit in the top-left, so reserve the titlebar gap above the transcript.
|
||||
|
|
@ -436,12 +490,11 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||
// The live tail (newest turns) is exempt: virtualizing a turn
|
||||
// whose final size hasn't been remembered yet snaps it to a stale
|
||||
// height when it scrolls off, drifting stick-to-bottom up over old
|
||||
// turns. See isVirtualizedGroup.
|
||||
// turns. See liveTailStart.
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)',
|
||||
isVirtualizedGroup(indexInVisible, visibleGroups.length) &&
|
||||
'[contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]'
|
||||
indexInVisible < tailStart && '[contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]'
|
||||
)}
|
||||
key={group.id}
|
||||
>
|
||||
|
|
@ -461,7 +514,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||
</MessageRenderBoundary>
|
||||
</div>
|
||||
)),
|
||||
[visibleGroups, components, structuralSignature]
|
||||
[visibleGroups, components, structuralSignature, tailStart]
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue