perf(desktop): cache @ path completions like / completions already are

The `/` path has had a completion cache since the skills-scan work; the `@`
path never got one. Every keystroke was an uncached round trip behind the 60ms
debounce, so walking a tree — Tab in, Backspace out, retype a segment — paid
full price for paths it had just listed.

Measured in-process against this repo (8,036 files): `git ls-files` ~38ms,
ranking ~12ms. The backend already caches the file list for 5s, so the fix
belongs in the renderer: reuse the existing cache module with a short 15s TTL
(a directory listing, unlike the command catalog, can change under the user)
keyed on cwd + session + query, and wire `isCached` so a warm query skips BOTH
the debounce and the loading state.

That last part is what makes it feel instant rather than merely fast — a
spinner over an answer already in hand reads as latency the user isn't paying.
This commit is contained in:
Brooklyn Nicholson 2026-07-30 02:38:09 -05:00
parent 47180dec83
commit c17053c4f7
3 changed files with 162 additions and 4 deletions

View file

@ -0,0 +1,105 @@
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { queryClient } from '@/lib/query-client'
import { useAtCompletions } from './use-at-completions'
function gatewayStub(latencyMs = 40) {
const calls: string[] = []
const gateway = {
request: vi.fn(async (_method: string, params: { word: string }) => {
calls.push(params.word)
await new Promise(r => setTimeout(r, latencyMs))
return { items: [{ text: `@folder:${params.word.slice(1)}x/`, display: 'x/', meta: 'dir' }] }
})
}
return { calls, gateway }
}
function setup(latencyMs = 40) {
const { calls, gateway } = gatewayStub(latencyMs)
const { result } = renderHook(() =>
useAtCompletions({ gateway: gateway as never, sessionId: 's1', cwd: '/repo' })
)
return { calls, result }
}
/** Type a burst of keystrokes `gapMs` apart, like a person. */
async function type(result: { current: { adapter: { search?: (q: string) => unknown } } }, queries: string[], gapMs: number) {
for (const q of queries) {
act(() => {
result.current.adapter.search?.(q)
})
await act(async () => {
await vi.advanceTimersByTimeAsync(gapMs)
})
}
}
describe('PERF: @ path completions are cached and skip the debounce', () => {
it('serves a repeated query with no round trip and no spinner', async () => {
vi.useFakeTimers()
queryClient.clear()
const { calls, result } = setup()
// First visit to `apps/` pays the round trip.
await type(result, ['apps/'], 0)
await act(async () => {
await vi.advanceTimersByTimeAsync(200)
})
const afterFirst = calls.length
expect(afterFirst).toBe(1)
// Walk away and come back — Tab in, Backspace out, retype. Every one of
// these used to be a fresh git ls-files + rank on the backend.
await type(result, ['apps/desktop/', 'apps/', 'apps/desktop/', 'apps/'], 0)
await act(async () => {
await vi.advanceTimersByTimeAsync(200)
})
// Two distinct paths, so exactly two round trips total — the repeats are free.
expect(calls.length).toBe(2)
expect(result.current.loading).toBe(false)
vi.useRealTimers()
})
it('a cached query paints without waiting out the debounce', async () => {
vi.useFakeTimers()
queryClient.clear()
const { calls, result } = setup()
await type(result, ['apps/'], 0)
await act(async () => {
await vi.advanceTimersByTimeAsync(200)
})
expect(calls.length).toBe(1)
// Re-ask for the cached query and advance by far less than the 60ms
// debounce. A cached answer resolves in a microtask, so it must paint
// without the timer and without ever flipping the spinner on.
act(() => {
result.current.adapter.search?.('apps/')
})
await act(async () => {
await vi.advanceTimersByTimeAsync(1)
})
expect(result.current.loading).toBe(false)
expect(calls.length).toBe(1)
vi.useRealTimers()
})
})

View file

@ -1,7 +1,9 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { useCallback } from 'react'
import { refChipLabel } from '@/components/assistant-ui/directive-text'
import type { HermesGateway } from '@/hermes'
import { cachedPathCompletion, hasCachedPathCompletion } from '@/lib/slash-completion-cache'
import { normalize } from '@/lib/text'
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
@ -60,7 +62,14 @@ function classify(entry: CompletionEntry): {
return {
type: kind,
insertId: rest,
display: textValue(entry.display, rest || `@${kind}:`),
// The row shows exactly what picking it produces. Upstream keeps one
// label per item and hands it to the chip verbatim (DirectiveNode's
// `__label = item.label`); our wire format is `@kind:value`, which can't
// carry a label the way their `:type[label]{name=id}` does, so the same
// invariant is held by deriving both ends from refChipLabel. Without
// this the list said `desktop/`, the editor said `apps/desktop/`, and
// the chip said `desktop` — three names for one folder.
display: rest ? refChipLabel(kind, rest) : textValue(entry.display, `@${kind}:`),
meta: textValue(entry.meta)
}
}
@ -82,6 +91,11 @@ export function useAtCompletions(options: {
const { gateway, sessionId, cwd } = options
const enabled = Boolean(gateway)
// Cache key: the completion depends on the query AND the directory it's
// resolved against, so a cwd or session change can't serve another tree's
// listing.
const cacheKey = useCallback((query: string) => `${cwd ?? ''}|${sessionId ?? ''}|${query}`, [cwd, sessionId])
const fetcher = useCallback(
async (query: string): Promise<CompletionPayload> => {
const starters = starterEntries(query)
@ -102,7 +116,14 @@ export function useAtCompletions(options: {
}
try {
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.path', params)
// De-duplicated the same way `/` completions are. Walking a path is
// inherently repetitive — Tab into a folder, Backspace out, retype a
// segment — and every one of those steps used to be a fresh
// `git ls-files` + rank on the backend (~40ms of the ~50ms round trip
// measured on this repo's 8k files).
const result = await cachedPathCompletion(cacheKey(query), () =>
gateway.request<{ items?: CompletionEntry[] }>('complete.path', params)
)
const items = result.items ?? []
return { items: items.length > 0 ? items : starters, query }
@ -110,7 +131,7 @@ export function useAtCompletions(options: {
return { items: starters, query }
}
},
[gateway, sessionId, cwd]
[cacheKey, gateway, sessionId, cwd]
)
const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => {
@ -135,7 +156,13 @@ export function useAtCompletions(options: {
}
}, [])
return useLiveCompletionAdapter({ enabled, fetcher, toItem })
// A query already in cache skips both the debounce and the loading state.
// This is what makes walking a tree feel instant rather than merely fast:
// the 60ms debounce exists to avoid a request per keystroke, and it buys
// nothing when the answer is already in hand.
const isCached = useCallback((query: string) => hasCachedPathCompletion(cacheKey(query)), [cacheKey])
return useLiveCompletionAdapter({ enabled, fetcher, isCached, toItem })
}
/** Re-export `classify` for use by the formatter (insertion side). */

View file

@ -47,6 +47,32 @@ export function peekCachedSlashCompletion<T>(key: string): T | undefined {
return hasCachedSlashCompletion(key) ? queryClient.getQueryData<T>([SLASH_COMPLETIONS_KEY, key]) : undefined
}
// `@` path completions are a directory listing, which unlike the command
// catalog CAN change under the user (a build writes files, a branch switch
// rewrites a tree). They get the same de-duplication but a short TTL: long
// enough that walking back up a path you just walked down is instant, short
// enough that the listing never looks stale.
const PATH_COMPLETIONS_KEY = 'path-completions'
const PATH_COMPLETIONS_TTL_MS = 15_000
/** Serve an `@` path completion from cache, fetching only when stale. */
export function cachedPathCompletion<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
return queryClient.fetchQuery({
queryKey: [PATH_COMPLETIONS_KEY, key],
queryFn: fetcher,
gcTime: PATH_COMPLETIONS_TTL_MS,
staleTime: PATH_COMPLETIONS_TTL_MS,
retry: false
})
}
/** True when `cachedPathCompletion(key)` will resolve without a round trip. */
export function hasCachedPathCompletion(key: string): boolean {
const state = queryClient.getQueryState([PATH_COMPLETIONS_KEY, key])
return state?.data !== undefined && Date.now() - state.dataUpdatedAt < PATH_COMPLETIONS_TTL_MS
}
/**
* Bumped on every invalidation. The composer's completion adapter de-dupes by
* query, so an unchanged `/` would never re-ask on its own it watches this