From 341f95b249409d0da1018ef6f7c7a3a42edccff3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 03:19:51 -0500 Subject: [PATCH 1/2] feat(desktop): cache slash completions and list skills on a bare / 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. --- .../hooks/use-live-completion-adapter.ts | 37 +++++++-- .../hooks/use-slash-completions.test.tsx | 82 +++++++++++++++++++ .../composer/hooks/use-slash-completions.ts | 45 ++++++++-- .../hooks/use-message-stream/gateway-event.ts | 8 ++ apps/desktop/src/app/skills/index.tsx | 8 ++ .../desktop/src/lib/slash-completion-cache.ts | 71 ++++++++++++++++ apps/desktop/src/store/hub-actions.ts | 4 + 7 files changed, 245 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx create mode 100644 apps/desktop/src/lib/slash-completion-cache.ts diff --git a/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts b/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts index 32301cc8294e..d35c78ea7b3f 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts @@ -25,9 +25,18 @@ export function useLiveCompletionAdapter(options: { enabled: boolean debounceMs?: number fetcher: (query: string) => Promise + /** 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( diff --git a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx new file mode 100644 index 000000000000..865a1239f5d6 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx @@ -0,0 +1,82 @@ +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 { 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() + + 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') + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts index bf6e5006beae..0f023a1d3f1c 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts @@ -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 => { @@ -133,7 +136,9 @@ export function useSlashCompletions(options: { try { if (!query) { - const catalog = filterDesktopCommandsCatalog(await gateway.request('commands.catalog')) + const catalog = filterDesktopCommandsCatalog( + await cachedSlashCompletion('catalog', () => gateway.request('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 }) } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 500540b6ccfb..b90f57e282a1 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -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) } diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx index 6349e39a00aa..1cd88fe2aaf0 100644 --- a/apps/desktop/src/app/skills/index.tsx +++ b/apps/desktop/src/app/skills/index.tsx @@ -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) diff --git a/apps/desktop/src/lib/slash-completion-cache.ts b/apps/desktop/src/lib/slash-completion-cache.ts new file mode 100644 index 000000000000..a25a0267607b --- /dev/null +++ b/apps/desktop/src/lib/slash-completion-cache.ts @@ -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(key: string, fetcher: () => Promise): Promise { + 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 +}) diff --git a/apps/desktop/src/store/hub-actions.ts b/apps/desktop/src/store/hub-actions.ts index 3483cb589bd1..4779060e7e9c 100644 --- a/apps/desktop/src/store/hub-actions.ts +++ b/apps/desktop/src/store/hub-actions.ts @@ -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 From 3ae9b1692825de251915c8020616cbf6425e3418 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 03:25:00 -0500 Subject: [PATCH 2/2] test(desktop): cover skills-only completions for a mid-message slash --- .../hooks/use-slash-completions.test.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx index 865a1239f5d6..eebed60ce4df 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx +++ b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx @@ -6,6 +6,8 @@ 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 = { @@ -79,4 +81,18 @@ describe('useSlashCompletions', () => { 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']) + }) })