341f95b249
The composer re-asked the gateway for the command catalog on every open and for complete.slash on every keystroke. Both scan the skills dir on the backend, so opening the menu cost a round trip against data that only moves when a skill is added, removed, or toggled. Hold both behind a one-hour cache keyed per query, and invalidate it where the skill set actually changes: hub install/uninstall/update, a Capabilities toggle, bulk toggle, archive, the agent's own skill_manage call, and a profile switch. A cached query also skips the debounce and the spinner, so a warm menu opens in the same frame. The bare-slash list showed no skills at all: the backend categorizes registry commands but appends skills to the flat pairs list only, and the popover prefers the categorized layout. Re-add the uncategorized leftovers under a Skills header, so /clean and /work are visible on / and not just after typing enough of the name to match.
72 lines
3.0 KiB
TypeScript
72 lines
3.0 KiB
TypeScript
import { atom } from 'nanostores'
|
|
|
|
import { queryClient } from '@/lib/query-client'
|
|
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
|
|
|
// Root for every cached `/` completion response — the bare-slash catalog and
|
|
// each typed query. Not in PROFILE_INDEPENDENT_QUERY_ROOTS, so a profile or
|
|
// gateway switch drops it with the rest of the profile-scoped cache.
|
|
const SLASH_COMPLETIONS_KEY = 'slash-completions'
|
|
|
|
// The command catalog and its completions are a scan of the command registry
|
|
// plus the skills on disk: they change when a skill is added, removed, or
|
|
// toggled — not while the user types. Both gateway calls are expensive on the
|
|
// backend (a full skills-dir scan per request), so hold the answer for an hour
|
|
// and let the mutation sites invalidate it when the truth actually moves.
|
|
const SLASH_COMPLETIONS_TTL_MS = 60 * 60_000
|
|
|
|
/** Serve a `/` completion response from cache, fetching only when stale. */
|
|
export function cachedSlashCompletion<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
|
return queryClient.fetchQuery({
|
|
queryKey: [SLASH_COMPLETIONS_KEY, key],
|
|
queryFn: fetcher,
|
|
gcTime: SLASH_COMPLETIONS_TTL_MS,
|
|
staleTime: SLASH_COMPLETIONS_TTL_MS,
|
|
// A completion is only worth having while the popover is open. Retrying a
|
|
// failed lookup with backoff would spend seconds answering a keystroke the
|
|
// user has already typed past; the caller falls back to an empty list and
|
|
// the next keystroke asks again.
|
|
retry: false
|
|
})
|
|
}
|
|
|
|
/** True when `cachedSlashCompletion(key)` will resolve without a round trip. */
|
|
export function hasCachedSlashCompletion(key: string): boolean {
|
|
const state = queryClient.getQueryState([SLASH_COMPLETIONS_KEY, key])
|
|
|
|
return state?.data !== undefined && Date.now() - state.dataUpdatedAt < SLASH_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
|
|
* instead to know the answer it's holding is no longer current.
|
|
*/
|
|
export const $slashCompletionsEpoch = atom(0)
|
|
|
|
/**
|
|
* Drop cached `/` completions. Called from every site that changes which
|
|
* skills exist or are enabled — install/uninstall/update from the hub, a
|
|
* skill toggle or delete in Capabilities — so the composer's list matches
|
|
* the backend without waiting out the TTL.
|
|
*/
|
|
export function invalidateSlashCompletions(): void {
|
|
void queryClient.invalidateQueries({ queryKey: [SLASH_COMPLETIONS_KEY] })
|
|
$slashCompletionsEpoch.set($slashCompletionsEpoch.get() + 1)
|
|
}
|
|
|
|
// Each profile has its own skills directory, so a cached catalog is only valid
|
|
// for the profile that produced it. Dropped at the source rather than in the
|
|
// composer so it holds whether or not a chat is mounted at switch time.
|
|
let cachedProfile: null | string = null
|
|
|
|
$activeGatewayProfile.subscribe(value => {
|
|
const key = normalizeProfileKey(value)
|
|
|
|
if (cachedProfile !== null && cachedProfile !== key) {
|
|
invalidateSlashCompletions()
|
|
}
|
|
|
|
cachedProfile = key
|
|
})
|