From 975f4ef38d2ff671172edd2078b75f05bf3b87d0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 02:14:22 -0500 Subject: [PATCH 1/2] perf(desktop): budget the transcript live tail in parts, not turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 | --- .../assistant-ui/thread/list.test.ts | 92 +++++++++++++++---- .../components/assistant-ui/thread/list.tsx | 75 ++++++++++++--- 2 files changed, 137 insertions(+), 30 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 e053af6f5ba..a09d57deba4 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.test.ts +++ b/apps/desktop/src/components/assistant-ui/thread/list.test.ts @@ -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) - }) }) diff --git a/apps/desktop/src/components/assistant-ui/thread/list.tsx b/apps/desktop/src/components/assistant-ui/thread/list.tsx index 3afbc324c4f..ba4b7964152 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/list.tsx @@ -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 = ({ @@ -278,6 +325,13 @@ const ThreadMessageListInner: FC = ({ 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 = ({ // 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.
@@ -461,7 +514,7 @@ const ThreadMessageListInner: FC = ({
)), - [visibleGroups, components, structuralSignature] + [visibleGroups, components, structuralSignature, tailStart] ) return ( From 04817665619864e96ac40d78dca74d084f9e2746 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 02:32:44 -0500 Subject: [PATCH 2/2] =?UTF-8?q?perf(desktop):=20make=20=E2=8C=98K=20cost?= =?UTF-8?q?=20nothing=20until=20it=20opens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⌘K is an overlay that is stateful to itself — pressing it owes the user a frame immediately, whatever else the shell is doing. It was not built that way. `CommandPalette` is mounted for the life of the app, and its body ran unconditionally: a dozen store subscriptions (connection, desktop version, client + backend update status/apply, keybinds, worktrees, theme, i18n), three `useQuery`s, and the group builders that assemble a few hundred rows. `` renders nothing while closed, so none of it was ever visible — but all of it still ran. An in-flight update rewrites `$updateApply` on every progress line, and each of those rebuilt the entire row set for a surface nobody could see. Split the body into `CommandPaletteBody`, mounted only while the palette is on screen. A closed palette is now one store subscription. The body is keyed by open count, so per-open state (search, sub-page) resets by remount and the explicit close-reset effect goes away, and `mounted` lags `open` by the 150ms exit animation so Radix can still play `data-[state=closed]` instead of the overlay vanishing. Rows additionally move behind `useDeferredValue` in their own memo component. Because that component mounts with the portal, the deferred initial value applies per open: the first commit is the frame + input, and the several-hundred-row list arrives in an interruptible follow-up render rather than blocking the frame the keypress asked for. The empty state is suppressed while rows are still pending so opening doesn't flash "no results". The `enabled: open` gates on the three queries are dropped — the component only exists when open, so they are inherently lazy, and react-query still serves a reopen from cache while revalidating. --- .../desktop/scripts/probe-command-palette.mjs | 146 ++++++++ .../desktop/src/app/command-palette/index.tsx | 347 ++++++++++++------ 2 files changed, 371 insertions(+), 122 deletions(-) create mode 100644 apps/desktop/scripts/probe-command-palette.mjs diff --git a/apps/desktop/scripts/probe-command-palette.mjs b/apps/desktop/scripts/probe-command-palette.mjs new file mode 100644 index 00000000000..1b48d81df8a --- /dev/null +++ b/apps/desktop/scripts/probe-command-palette.mjs @@ -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() diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index ff7af6b5c91..260e22db955 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -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 + 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 && ( +
{noResultsLabel}
+ )} + {deferred.map((group, index) => ( + + {group.items.map(item => ( + + ))} + + ))} + + ) +}) + const PaletteRow = memo(function PaletteRow({ bindings, item, @@ -277,7 +341,9 @@ const PaletteRow = memo(function PaletteRow({ )} {item.detail && {item.detail}} - {combo && } + {combo && ( + + )} {item.to && } {item.active && } @@ -375,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 ( + + {mounted && } + + ) +} + +function CommandPaletteBody({ onExited }: { onExited: () => void }) { + const { t } = useI18n() const pendingPage = useStore($commandPalettePage) const bindings = useStore($bindings) const worktrees = useStore($repoWorktrees) @@ -439,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) @@ -457,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(() => { @@ -490,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]) @@ -1099,102 +1212,92 @@ export function CommandPalette() { } return ( - - - {/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */} - - + {/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */} + + { + if (event.target === event.currentTarget && event.currentTarget.dataset.state === 'closed') { + onExited() + } + }} + > + {t.commandCenter.paletteTitle} + + {activePage && ( + )} - > - {t.commandCenter.paletteTitle} - - {activePage && ( - + { + // 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' ? : undefined} + value={search} + /> + + {/* Server-driven pages render their own list; the rest show groups. */} + {page === 'pets' ? ( + { + closeCommandPalette() + openPetGenerate() + }} + search={search} + /> + ) : page === 'install-theme' ? ( + + ) : ( + )} - { - // 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' ? : undefined} - value={search} - /> - - {/* Server-driven pages render their own list; the rest show groups. */} - {page === 'pets' ? ( - { - closeCommandPalette() - openPetGenerate() - }} - search={search} - /> - ) : page === 'install-theme' ? ( - - ) : ( - <> - {/* Filtering happens in rankGroups, so cmdk's own CommandEmpty - (keyed to its internal filter count) would never fire. */} - {visibleGroups.length === 0 && ( -
{t.commandCenter.noResults}
- )} - {visibleGroups.map((group, index) => ( - - {group.items.map(item => ( - - ))} - - ))} - - )} -
-
-
-
-
+ + + + ) }