Merge pull request #73229 from NousResearch/bb/slash-catalog-cache

Cache slash completions and show skills on a bare /
This commit is contained in:
brooklyn! 2026-07-28 03:34:45 -05:00 committed by GitHub
commit 48cadf197e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 261 additions and 10 deletions

View file

@ -25,9 +25,18 @@ export function useLiveCompletionAdapter(options: {
enabled: boolean
debounceMs?: number
fetcher: (query: string) => Promise<CompletionPayload>
/** True when `fetcher` will answer this query from cache. Such a query skips
* both the debounce and the loading state the debounce exists to avoid a
* request per keystroke, and a spinner over an answer we already hold reads
* as latency the user isn't actually paying. */
isCached?: (query: string) => boolean
/** Bump to declare the held answer stale. Without it a popover left open on
* an unchanged query would keep serving what it fetched before the source
* changed, because the adapter de-dupes on the query alone. */
epoch?: number
toItem: (entry: CompletionEntry, index: number) => Unstable_TriggerItem
}): { adapter: Unstable_TriggerAdapter; loading: boolean } {
const { enabled, debounceMs = 60, fetcher, toItem } = options
const { enabled, debounceMs = 60, epoch = 0, fetcher, isCached, toItem } = options
const [state, setState] = useState<{ query: string; items: Unstable_TriggerItem[] }>({
query: EMPTY_QUERY,
@ -62,6 +71,16 @@ export function useLiveCompletionAdapter(options: {
setState({ query: EMPTY_QUERY, items: [] })
}, [cancelTimer, enabled])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
// Invalidate by forgetting which query the held items answer, so the next
// search() re-fetches. The items themselves stay until the new answer
// lands — an open popover must not blink empty on a background refresh.
// On mount this is already the state, so the first run is a no-op.
pendingQueryRef.current = null
setState(current => (current.query === EMPTY_QUERY ? current : { ...current, query: EMPTY_QUERY }))
}, [epoch])
const scheduleFetch = useCallback(
(query: string) => {
if (!enabled) {
@ -75,9 +94,13 @@ export function useLiveCompletionAdapter(options: {
pendingQueryRef.current = query
cancelTimer()
const token = ++tokenRef.current
setLoading(true)
const cached = isCached?.(query) ?? false
timerRef.current = window.setTimeout(() => {
if (!cached) {
setLoading(true)
}
const run = () => {
timerRef.current = null
fetcher(query)
@ -103,9 +126,13 @@ export function useLiveCompletionAdapter(options: {
setLoading(false)
}
})
}, debounceMs)
}
// A cached answer resolves in a microtask, so debouncing it would only
// add a frame of empty popover on every keystroke.
cached ? run() : (timerRef.current = window.setTimeout(run, debounceMs))
},
[cancelTimer, debounceMs, enabled, fetcher, toItem]
[cancelTimer, debounceMs, enabled, fetcher, isCached, toItem]
)
const adapter = useMemo<Unstable_TriggerAdapter>(

View file

@ -0,0 +1,98 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HermesGateway } from '@/hermes'
import { queryClient } from '@/lib/query-client'
import { invalidateSlashCompletions } from '@/lib/slash-completion-cache'
import { isSkillItem } from '../composer-utils'
import { useSlashCompletions } from './use-slash-completions'
const CATALOG = {
categories: [{ name: 'Session', pairs: [['/new', 'Start a new session']] }],
pairs: [
['/new', 'Start a new session'],
['/work', 'Kick off a task in a fresh worktree']
]
}
function harness(gateway: HermesGateway) {
const api: { search?: (query: string) => readonly Unstable_TriggerItem[] } = {}
function Probe() {
const { adapter } = useSlashCompletions({ gateway })
api.search = adapter.search
return null
}
render(<Probe />)
return api as { search: (query: string) => readonly Unstable_TriggerItem[] }
}
/** Drive the adapter until its async fetch has settled into `search`'s result. */
async function completions(api: { search: (query: string) => readonly Unstable_TriggerItem[] }, query: string) {
await act(async () => {
api.search(query)
await Promise.resolve()
})
// The debounce is skipped only for cached queries; give the timer a beat.
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 120))
})
return api.search(query)
}
afterEach(() => {
cleanup()
queryClient.clear()
})
describe('useSlashCompletions', () => {
it('serves the bare-slash catalog from cache instead of re-requesting it', async () => {
const request = vi.fn().mockResolvedValue(CATALOG)
const api = harness({ request } as unknown as HermesGateway)
await completions(api, '')
expect(request).toHaveBeenCalledTimes(1)
// Reopening `/` must not hit the gateway again.
queryClient.setQueryData(['unrelated'], 1)
await completions(api, '')
expect(request).toHaveBeenCalledTimes(1)
// …until something that changes the command set invalidates it.
await act(async () => invalidateSlashCompletions())
await completions(api, '')
expect(request).toHaveBeenCalledTimes(2)
})
it('offers skill commands on a bare slash, not just built-ins', async () => {
const request = vi.fn().mockResolvedValue(CATALOG)
const api = harness({ request } as unknown as HermesGateway)
const items = await completions(api, '')
const work = items.find(item => (item.metadata as { command?: string })?.command === '/work')
expect((work?.metadata as { group?: string })?.group).toBe('Skills')
})
// A `/` typed mid-message is a reference dropped into prose, so the trigger
// filters the list to skills (use-composer-trigger). A bare mid-message `/`
// resolves to the same empty query as an opening `/`, so that filter runs
// over the catalog — which listed no skill-group rows at all, leaving the
// inline popover empty. Asserted through isSkillItem, the real predicate.
it('leaves only skills for a mid-message slash', async () => {
const request = vi.fn().mockResolvedValue(CATALOG)
const api = harness({ request } as unknown as HermesGateway)
const inline = (await completions(api, '')).filter(isSkillItem)
expect(inline.map(item => (item.metadata as { command?: string })?.command)).toEqual(['/work'])
})
})

View file

@ -1,4 +1,5 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { useStore } from '@nanostores/react'
import { useCallback } from 'react'
import type { HermesGateway } from '@/hermes'
@ -12,6 +13,7 @@ import {
isDesktopSlashExtensionCommand,
isDesktopSlashSuggestion
} from '@/lib/desktop-slash-commands'
import { $slashCompletionsEpoch, cachedSlashCompletion, hasCachedSlashCompletion } from '@/lib/slash-completion-cache'
import { normalize } from '@/lib/text'
import { $sessions } from '@/store/session'
@ -63,6 +65,7 @@ export function useSlashCompletions(options: {
} {
const { gateway, skinThemes, activeSkin } = options
const enabled = Boolean(gateway)
const epoch = useStore($slashCompletionsEpoch)
const fetcher = useCallback(
async (query: string): Promise<CompletionPayload> => {
@ -133,7 +136,9 @@ export function useSlashCompletions(options: {
try {
if (!query) {
const catalog = filterDesktopCommandsCatalog(await gateway.request<CommandsCatalogLike>('commands.catalog'))
const catalog = filterDesktopCommandsCatalog(
await cachedSlashCompletion('catalog', () => gateway.request<CommandsCatalogLike>('commands.catalog'))
)
// Prefer the categorized layout so the popover renders section headers
// (Session, Tools & Skills, ...). Fall back to the flat list when the
@ -149,12 +154,26 @@ export function useSlashCompletions(options: {
}))
)
// Skill commands reach us only through the flat `pairs` list — the
// backend categorizes registry commands but appends skills
// uncategorized, so the categorized layout alone drops every skill
// from the bare `/` list even though typing `/wo` offers them.
// Re-add the leftovers under one Skills header (which also gives them
// the skill pill accent and makes them offerable mid-message).
const categorized = new Set(items.map(item => item.text.toLowerCase()))
for (const [command, meta] of catalog.pairs ?? []) {
if (!categorized.has(command.toLowerCase()) && isDesktopSlashExtensionCommand(command)) {
items.push({ text: command, display: command, group: 'Skills', meta })
}
}
return { items, query }
}
const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>('complete.slash', {
text
})
const result = await cachedSlashCompletion(`slash:${text.toLowerCase()}`, () =>
gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>('complete.slash', { text })
)
// Arg-completion items (replace_from > 1) carry just the arg stub —
// e.g. complete.slash returns `{text: "alice"}` for `/personality alic`
@ -231,5 +250,21 @@ export function useSlashCompletions(options: {
}
}, [])
return useLiveCompletionAdapter({ enabled, fetcher, toItem })
// Mirrors the fetcher's branching: the `/skin` and `/resume` arg stages are
// answered from client-side state, so they never wait on the network; every
// other query is served from the completion cache when it's still warm.
const isCached = useCallback(
(query: string) => {
const text = `/${query}`
if ((skinThemes && /^\/skin\s+/is.test(text)) || /^\/(?:resume|sessions|switch)\s+/is.test(text)) {
return true
}
return hasCachedSlashCompletion(query ? `slash:${text.toLowerCase()}` : 'catalog')
},
[skinThemes]
)
return useLiveCompletionAdapter({ enabled, epoch, fetcher, isCached, toItem })
}

View file

@ -15,6 +15,7 @@ import { resolveGatewayEventSessionId } from '@/lib/gateway-events'
import { triggerHaptic } from '@/lib/haptics'
import { modelOptionsQueryKey } from '@/lib/model-options'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { invalidateSlashCompletions } from '@/lib/slash-completion-cache'
import { type AgentNoticePayload, clearAgentNotice, nativeNoticeInput, showAgentNotice } from '@/store/agent-notices'
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block'
@ -756,6 +757,13 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
}
}
// The agent just created/deleted/renamed a skill, which adds or removes
// its `/name` command. Drop the composer's cached `/` list so the new
// skill is offerable now rather than after the hour-long TTL.
if (payload?.name === 'skill_manage') {
invalidateSlashCompletions()
}
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
recordToolDiff(payload.tool_id || payload.name || '', payload.inline_diff)
}

View file

@ -24,6 +24,7 @@ import { useI18n } from '@/i18n'
import { isDesktopToolsetVisible } from '@/lib/desktop-toolsets'
import { compactNumber } from '@/lib/format'
import { queryClient, writeCache } from '@/lib/query-client'
import { invalidateSlashCompletions } from '@/lib/slash-completion-cache'
import { normalize } from '@/lib/text'
import { $gateway } from '@/store/gateway'
import { notify, notifyError } from '@/store/notifications'
@ -222,6 +223,8 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
queryClient.invalidateQueries({ queryKey: TOOLSETS_QUERY_KEY })
])
invalidateSlashCompletions()
// An explicit refresh is the one time we bypass the analytics TTL — but
// only if the badges are already on screen; otherwise let the lazy load
// pick it up when Toolsets is first shown. Guard the async set against a
@ -336,6 +339,9 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
try {
await toggleSkill(skill.name, enabled)
// A disabled skill loses its `/name` command, so the composer's cached
// `/` list has to be dropped along with the row repaint.
invalidateSlashCompletions()
} catch (err) {
setSkills(
current => current?.map(row => (row.name === skill.name ? { ...row, enabled: !enabled } : row)) ?? current
@ -390,6 +396,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
} catch (err) {
notifyError(err, t.skills.failedToUpdate(mode === 'skills' ? t.skills.tabSkills : t.skills.tabToolsets))
} finally {
invalidateSlashCompletions()
setBulkBusy(false)
}
}
@ -674,6 +681,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
const snapshot = skills
setSkills(current => current?.filter(skill => skill.name !== name) ?? current)
invalidateSlashCompletions()
if (skillEditor?.name === name) {
setSkillEditor(null)

View file

@ -0,0 +1,71 @@
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
})

View file

@ -2,6 +2,7 @@ import { atom, map } from 'nanostores'
import { getActionStatus, installSkillFromHub, uninstallSkillFromHub, updateSkillsFromHub } from '@/hermes'
import { queryClient } from '@/lib/query-client'
import { invalidateSlashCompletions } from '@/lib/slash-completion-cache'
import { upsertDesktopActionTask } from '@/store/activity'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
@ -104,6 +105,9 @@ async function runHubAction(key: string, kind: HubActionKind, spawn: () => Promi
// (un)install adds/removes a skill, so its count/rows must update too.
void queryClient.invalidateQueries({ queryKey: HUB_SOURCES_KEY })
void queryClient.invalidateQueries({ queryKey: SKILLS_LIST_KEY })
// …and the composer's `/` list, which caches the command catalog for an
// hour and would otherwise keep offering the skill we just removed.
invalidateSlashCompletions()
} catch (err) {
// A profile switch points the next poll at the new backend, which 404s the
// old action name — that's an abandonment, not a failure, so swallow it