mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
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:
parent
47180dec83
commit
c17053c4f7
3 changed files with 162 additions and 4 deletions
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue