diff --git a/apps/desktop/src/app/chat/composer/hooks/use-at-completions.test.ts b/apps/desktop/src/app/chat/composer/hooks/use-at-completions.test.ts new file mode 100644 index 00000000000..b133a7060ff --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-at-completions.test.ts @@ -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() + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-at-completions.ts b/apps/desktop/src/app/chat/composer/hooks/use-at-completions.ts index d56a6d57e71..5a53f46e9ea 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-at-completions.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-at-completions.ts @@ -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 => { 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). */ diff --git a/apps/desktop/src/lib/slash-completion-cache.ts b/apps/desktop/src/lib/slash-completion-cache.ts index 1be66cdbdad..7b87c4c746f 100644 --- a/apps/desktop/src/lib/slash-completion-cache.ts +++ b/apps/desktop/src/lib/slash-completion-cache.ts @@ -47,6 +47,32 @@ export function peekCachedSlashCompletion(key: string): T | undefined { return hasCachedSlashCompletion(key) ? queryClient.getQueryData([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(key: string, fetcher: () => Promise): Promise { + 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