Merge pull request #74665 from NousResearch/bb/palette-perf

perf(desktop): ⌘K opens instantly, whatever else the shell is doing
This commit is contained in:
brooklyn! 2026-07-30 03:08:58 -05:00 committed by GitHub
commit d437d3e54d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 505 additions and 151 deletions

View file

@ -0,0 +1,146 @@
// ⌘K open latency, measured in-page (no CDP round-trip in the number).
//
// node scripts/probe-command-palette.mjs [--port 9222] [--rounds 8]
//
// Reports, per round, the time from the keydown the app actually receives to:
// frame_ms — the dialog frame + input in the DOM and painted (what "instant"
// means: the overlay owes you a frame immediately)
// rows_ms — the row list painted (may lag frame_ms; rows are deferred)
// plus any long tasks in the window, so a slow open is attributable.
import { CDP, sleep } from './perf/lib/cdp.mjs'
const args = process.argv.slice(2)
const flag = name => {
const i = args.indexOf(`--${name}`)
return i >= 0 ? args[i + 1] : undefined
}
const port = Number(flag('port') ?? 9222)
const rounds = Number(flag('rounds') ?? 8)
const cdp = await CDP.connect({ port })
await cdp.send('Runtime.enable')
const INSTALL = `
(() => {
if (window.__CMDK__) window.__CMDK__.stop()
const state = { t0: null, frame: null, rows: 0, rowsAt: null, tasks: [], armed: false }
// Time from the keydown the APP receives — excludes CDP transport, so the
// number is what a user's finger actually experiences.
const onKey = e => {
if (state.armed && (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
state.t0 = performance.now()
state.armed = false
}
}
window.addEventListener('keydown', onKey, true)
const obs = new MutationObserver(() => {
if (state.t0 === null) return
if (state.frame === null && document.querySelector('[cmdk-input]')) {
state.frame = performance.now() - state.t0
}
const n = document.querySelectorAll('[cmdk-item]').length
if (n > state.rows) { state.rows = n; state.rowsAt = performance.now() - state.t0 }
})
obs.observe(document.body, { childList: true, subtree: true })
const po = new PerformanceObserver(list => {
for (const e of list.getEntries()) state.tasks.push({ start: e.startTime, dur: Math.round(e.duration) })
})
try { po.observe({ entryTypes: ['longtask'] }) } catch {}
window.__CMDK__ = {
arm: () => { state.t0 = null; state.frame = null; state.rows = 0; state.rowsAt = null; state.tasks = []; state.armed = true },
read: () => ({
frame_ms: state.frame === null ? -1 : Math.round(state.frame),
rows_ms: state.rowsAt === null ? -1 : Math.round(state.rowsAt),
rows: state.rows,
longtask_ms: state.t0 === null ? 0 : state.tasks.filter(t => t.start >= state.t0).reduce((s, t) => s + t.dur, 0)
}),
stop: () => { window.removeEventListener('keydown', onKey, true); obs.disconnect(); po.disconnect() }
}
return true
})()
`
// Settle: frame painted AND rows stopped growing for two frames.
const WAIT = `
new Promise(resolve => {
let stable = 0
let last = -1
const started = performance.now()
const tick = () => {
const r = window.__CMDK__.read()
if (r.frame_ms >= 0 && r.rows === last && r.rows > 0) {
if (++stable >= 2) { resolve(r); return }
} else { stable = 0 }
last = r.rows
if (performance.now() - started > 8000) { resolve(window.__CMDK__.read()); return }
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
})
`
const key = async type =>
cdp.send('Input.dispatchKeyEvent', {
type,
key: 'k',
code: 'KeyK',
windowsVirtualKeyCode: 75,
nativeVirtualKeyCode: 75,
modifiers: 4
})
const esc = async () => {
for (const type of ['keyDown', 'keyUp']) {
await cdp.send('Input.dispatchKeyEvent', { type, key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 })
}
await sleep(400)
}
await cdp.eval(INSTALL)
await esc()
const samples = []
for (let i = 0; i < rounds; i++) {
await sleep(250)
await cdp.eval('window.__CMDK__.arm()')
await key('rawKeyDown')
await key('keyUp')
const r = await cdp.eval(WAIT)
samples.push(r)
console.log(`round ${i}:`, r)
await esc()
}
await cdp.eval('window.__CMDK__.stop()')
const stat = k => {
const v = samples.map(s => s[k]).filter(n => n >= 0).sort((a, b) => a - b)
if (!v.length) return null
return {
min: v[0],
median: v[Math.floor(v.length / 2)],
max: v[v.length - 1]
}
}
console.log('\nkeydown → dialog frame painted (ms):', stat('frame_ms'))
console.log('keydown → rows painted (ms):', stat('rows_ms'))
console.log('long-task time in window (ms):', stat('longtask_ms'))
cdp.close()

View file

@ -1,7 +1,7 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { Dialog as DialogPrimitive } from 'radix-ui'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud'
@ -235,6 +235,70 @@ const rankGroups = (groups: PaletteGroup[], search: string): PaletteGroup[] => {
// theme lists under both Light and Dark). The id suffix disambiguates.
const paletteValue = (item: PaletteItem): string => `${item.label}\u0001${item.id}`
const EMPTY_GROUPS: PaletteGroup[] = []
// Backstop only. The palette normally retires on the content's real
// `animationend`, so the CSS owns the close duration; this just guarantees the
// body can't stay mounted forever somewhere animations never run (jsdom,
// `animation: none`). Deliberately longer than any plausible exit so it never
// races the real signal and truncates the fade.
const EXIT_FALLBACK_MS = 1000
/**
* The palette's row list, split out so an OPENING palette paints before it
* renders rows. This component mounts with the portal, so `useDeferredValue`'s
* initial value applies per open: the first commit is the frame + input
* (instant), and the several-hundred-row list arrives in an interruptible
* follow-up render. Opening K must never wait on building the list.
*/
const PaletteGroups = memo(function PaletteGroups({
bindings,
groups,
modHeld,
noResultsLabel,
onSelectItem,
onSelectMods,
search
}: {
bindings: Record<string, string[]>
groups: PaletteGroup[]
modHeld: boolean
noResultsLabel: string
onSelectItem: (item: PaletteItem) => void
onSelectMods: (event: { ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }) => void
search: string
}) {
const deferred = useDeferredValue(groups, EMPTY_GROUPS)
// While the rows are still catching up, an empty list means "not rendered
// yet", not "nothing matched" — don't flash the empty state on open.
const pending = deferred !== groups
return (
<>
{/* Filtering happens in rankGroups, so cmdk's own CommandEmpty
(keyed to its internal filter count) would never fire. */}
{deferred.length === 0 && !pending && (
<div className="py-6 text-center text-sm text-muted-foreground">{noResultsLabel}</div>
)}
{deferred.map((group, index) => (
<CommandGroup className={HUD_HEADING} heading={group.heading} key={group.heading ?? `palette-group-${index}`}>
{group.items.map(item => (
<PaletteRow
bindings={bindings}
item={item}
key={item.id}
modHeld={modHeld}
onSelectItem={onSelectItem}
onSelectMods={onSelectMods}
search={search}
/>
))}
</CommandGroup>
))}
</>
)
})
const PaletteRow = memo(function PaletteRow({
bindings,
item,
@ -377,9 +441,68 @@ function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean {
return target === 'dark' ? luminance(background) <= 0.5 : luminance(background) > 0.5
}
/**
* K is an overlay that is stateful to itself: pressing it must open a frame
* immediately, and must not be held up by whatever else the shell is doing. So
* the mounted cost of a CLOSED palette is one store subscription and nothing
* else.
*
* Everything expensive a dozen store subscriptions (connection, update
* status/apply, keybinds, worktrees, projects, theme, i18n), three server
* queries, and the group builders that assemble a few hundred rows lives in
* `CommandPaletteBody`, which only exists while the palette is on screen.
* Before this split those hooks ran on every render of the always-mounted
* component: an in-flight update rewrote `$updateApply` per progress line and
* rebuilt the entire row set each time, for a surface nobody could see.
*
* `mounted` lags `open` by the close animation rather than tracking it exactly.
* Unmounting the body the instant `open` flips false would rip the content out
* of the tree before Radix could play `data-[state=closed]`, so the overlay
* would vanish instead of closing. The body reports its own exit via
* `onExited` (the content's real `animationend`), so nothing here has to know
* how long that animation is the CSS owns the duration.
*
* The `openCount` key remounts the body per open, which is what lets local
* search/sub-page state reset without a close effect.
*/
export function CommandPalette() {
const { t } = useI18n()
const open = useStore($commandPaletteOpen)
const [mounted, setMounted] = useState(open)
const [openCount, setOpenCount] = useState(0)
const retire = useCallback(() => {
// Only retire the body if the palette is still closed — a reopen mid-fade
// must not unmount the fresh instance.
if (!$commandPaletteOpen.get()) {
setMounted(false)
}
}, [])
useEffect(() => {
if (open) {
setOpenCount(count => count + 1)
setMounted(true)
return
}
// Safety net for environments where the exit animation never runs (jsdom,
// `animation: none`), so the body can't be stranded mounted. The real
// unmount is `onExited` below; whichever fires first wins.
const timer = setTimeout(retire, EXIT_FALLBACK_MS)
return () => clearTimeout(timer)
}, [open, retire])
return (
<DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
{mounted && <CommandPaletteBody key={openCount} onExited={retire} />}
</DialogPrimitive.Root>
)
}
function CommandPaletteBody({ onExited }: { onExited: () => void }) {
const { t } = useI18n()
const pendingPage = useStore($commandPalettePage)
const bindings = useStore($bindings)
const worktrees = useStore($repoWorktrees)
@ -441,12 +564,6 @@ export function CommandPalette() {
const [modHeld, setModHeld] = useState(false)
useEffect(() => {
if (!open) {
setModHeld(false)
return
}
const sync = (event: KeyboardEvent) => setModHeld(event.metaKey || event.ctrlKey)
const clear = () => setModHeld(false)
@ -459,26 +576,25 @@ export function CommandPalette() {
window.removeEventListener('keyup', sync, { capture: true })
window.removeEventListener('blur', clear)
}
}, [open])
}, [])
// Server-backed sources for the type-to-search groups, fetched lazily while
// the palette is open. react-query handles caching/dedup/staleness.
// Server-backed sources for the type-to-search groups. This component only
// exists while the palette is open, so the queries are inherently lazy — no
// `enabled` gate needed. react-query handles caching/dedup/staleness, so a
// reopen paints from cache and revalidates in the background.
const configQuery = useQuery({
queryKey: ['command-palette', 'config'],
queryFn: getHermesConfigRecord,
enabled: open
queryFn: getHermesConfigRecord
})
const sessionsQuery = useQuery({
queryKey: ['command-palette', 'sessions'],
queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
enabled: open
queryFn: () => listAllProfileSessions(200, 1, 'exclude')
})
const archivedQuery = useQuery({
queryKey: ['command-palette', 'archived'],
queryFn: () => listAllProfileSessions(200, 0, 'only'),
enabled: open
queryFn: () => listAllProfileSessions(200, 0, 'only')
})
const mcpServers = useMemo(() => {
@ -492,21 +608,16 @@ export function CommandPalette() {
const sessions = useMemo(() => (sessionsQuery.data?.sessions ?? []).map(toSessionEntry), [sessionsQuery.data])
const archivedSessions = useMemo(() => (archivedQuery.data?.sessions ?? []).map(toSessionEntry), [archivedQuery.data])
// Reset the query/sub-page on close so it reopens clean.
useEffect(() => {
if (!open) {
setSearch('')
setPage(null)
}
}, [open])
// Search/sub-page are local to a mount, and this component remounts per open
// (keyed by open count), so each open starts clean without a reset effect.
// Deep-link into a nested page (e.g. `/pet list` → pets picker).
useEffect(() => {
if (open && pendingPage) {
if (pendingPage) {
setPage(pendingPage)
$commandPalettePage.set(null)
}
}, [open, pendingPage])
}, [pendingPage])
const go = useCallback((path: string) => () => navigateToWorkspacePage(navigate, path), [navigate])
@ -1101,102 +1212,92 @@ export function CommandPalette() {
}
return (
<DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
<DialogPrimitive.Portal>
{/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */}
<DialogPrimitive.Overlay className="fixed inset-0 z-(--z-over-modal)" />
<DialogPrimitive.Content
aria-describedby={undefined}
className={cn(
HUD_POSITION,
HUD_SURFACE,
'z-(--z-over-modal-content) w-[min(34rem,calc(100vw-2rem))] overflow-hidden duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95'
<DialogPrimitive.Portal>
{/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */}
<DialogPrimitive.Overlay className="fixed inset-0 z-(--z-over-modal)" />
<DialogPrimitive.Content
aria-describedby={undefined}
className={cn(
HUD_POSITION,
HUD_SURFACE,
'z-(--z-over-modal-content) w-[min(34rem,calc(100vw-2rem))] overflow-hidden duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95'
)}
// The close animation finishing is what retires this whole subtree —
// the CSS owns the duration, not a hardcoded timer. Guarded on the
// content itself (descendants animate too) and on the closed state, so
// an OPEN animation never unmounts the palette we just opened.
onAnimationEnd={event => {
if (event.target === event.currentTarget && event.currentTarget.dataset.state === 'closed') {
onExited()
}
}}
>
<DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>
<Command className="bg-transparent" loop shouldFilter={false}>
{activePage && (
<button
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={goBack}
type="button"
>
<ChevronLeft className="size-3.5" />
<span>{t.commandCenter.back}</span>
<span className="text-muted-foreground/50">/</span>
<span className="font-medium text-foreground">{activePage.title}</span>
</button>
)}
>
<DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>
<Command className="bg-transparent" loop shouldFilter={false}>
{activePage && (
<button
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={goBack}
type="button"
>
<ChevronLeft className="size-3.5" />
<span>{t.commandCenter.back}</span>
<span className="text-muted-foreground/50">/</span>
<span className="font-medium text-foreground">{activePage.title}</span>
</button>
<CommandInput
className={HUD_TEXT}
onKeyDown={event => {
// Capture modifiers before cmdk's Enter fires onSelect (which
// swipes the inviting MouseEvent and hands us nothing).
noteSelectMods(event)
if (!activePage) {
return
}
// In a submenu: Esc and empty-input Backspace step back out
// instead of closing the whole palette.
if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
event.preventDefault()
event.stopPropagation()
goBack()
return
}
}}
onValueChange={setSearch}
placeholder={placeholder}
right={page === 'pets' ? <PetInlineToggle /> : undefined}
value={search}
/>
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
{/* Server-driven pages render their own list; the rest show groups. */}
{page === 'pets' ? (
<PetPalettePage
onGenerate={() => {
closeCommandPalette()
openPetGenerate()
}}
search={search}
/>
) : page === 'install-theme' ? (
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
) : (
<PaletteGroups
bindings={bindings}
groups={visibleGroups}
modHeld={modHeld}
noResultsLabel={t.commandCenter.noResults}
onSelectItem={handleSelect}
onSelectMods={noteSelectMods}
search={search}
/>
)}
<CommandInput
className={HUD_TEXT}
onKeyDown={event => {
// Capture modifiers before cmdk's Enter fires onSelect (which
// swipes the inviting MouseEvent and hands us nothing).
noteSelectMods(event)
if (!activePage) {
return
}
// In a submenu: Esc and empty-input Backspace step back out
// instead of closing the whole palette.
if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
event.preventDefault()
event.stopPropagation()
goBack()
return
}
}}
onValueChange={setSearch}
placeholder={placeholder}
right={page === 'pets' ? <PetInlineToggle /> : undefined}
value={search}
/>
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
{/* Server-driven pages render their own list; the rest show groups. */}
{page === 'pets' ? (
<PetPalettePage
onGenerate={() => {
closeCommandPalette()
openPetGenerate()
}}
search={search}
/>
) : page === 'install-theme' ? (
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
) : (
<>
{/* Filtering happens in rankGroups, so cmdk's own CommandEmpty
(keyed to its internal filter count) would never fire. */}
{visibleGroups.length === 0 && (
<div className="py-6 text-center text-sm text-muted-foreground">{t.commandCenter.noResults}</div>
)}
{visibleGroups.map((group, index) => (
<CommandGroup
className={HUD_HEADING}
heading={group.heading}
key={group.heading ?? `palette-group-${index}`}
>
{group.items.map(item => (
<PaletteRow
bindings={bindings}
item={item}
key={item.id}
modHeld={modHeld}
onSelectItem={handleSelect}
onSelectMods={noteSelectMods}
search={search}
/>
))}
</CommandGroup>
))}
</>
)}
</CommandList>
</Command>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
</CommandList>
</Command>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
)
}

View file

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

View file

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