diff --git a/apps/desktop/src/app/chat/composer/composer-utils.ts b/apps/desktop/src/app/chat/composer/composer-utils.ts index 617e65051119..7ad1d487a0d8 100644 --- a/apps/desktop/src/app/chat/composer/composer-utils.ts +++ b/apps/desktop/src/app/chat/composer/composer-utils.ts @@ -60,8 +60,10 @@ export function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind return 'command' } -/** True for a skill completion — the only kind offered mid-message. */ -export const isSkillItem = (item: Unstable_TriggerItem) => slashChipKindForItem(item) === 'skill' +/** True for a skill completion — the only kind offered mid-message, + * alongside plugin-contributed mentions (e.g. bot roster names). */ +export const isSkillItem = (item: Unstable_TriggerItem) => + slashChipKindForItem(item) === 'skill' || (item.metadata as { mention?: boolean } | undefined)?.mention === true /** A `/` query is at its arg stage once it's past the command name. */ export const slashArgStage = (query: string) => query.includes(' ') diff --git a/apps/desktop/src/app/chat/composer/contrib.ts b/apps/desktop/src/app/chat/composer/contrib.ts index 94d19457a58f..56549e560bf4 100644 --- a/apps/desktop/src/app/chat/composer/contrib.ts +++ b/apps/desktop/src/app/chat/composer/contrib.ts @@ -36,7 +36,8 @@ export const COMPOSER_AREAS = { actions: 'composer.actions', middleware: 'composer.middleware', attachments: 'composer.attachments', - microActions: 'composer.microActions' + microActions: 'composer.microActions', + mentions: 'composer.mentions' } as const export interface ComposerDraft { @@ -137,3 +138,48 @@ export function useComposerMicroActionProviders(): ComposerMicroActionProvider[] [contributions] ) } + +/** + * Payload of a `composer.mentions` data contribution — extra entries offered + * by the composer's `@` picker alongside file/URL/git references. + * + * `resolve` is called with the live session context and returns the entries + * to offer, or `[]` for "nothing from me". Entries render as a group headed + * by `group` when present; the text is what picking the row inserts (use the + * same wire shape the gateway's `complete.path` uses, e.g. `@researcher`). + */ +export interface ComposerMentionProvider { + resolve: (ctx: ComposerMentionContext) => ComposerMentionEntry[] +} + +export interface ComposerMentionEntry { + /** Raw text inserted on pick, e.g. `@researcher` (no trailing space — the + * composer adds it, matching file refs). */ + text: string + /** Row label. Defaults to `text`. */ + display?: string + /** Secondary line under the label (role/title). */ + meta?: string + /** Optional section header rendered above the group ("Bots"). When absent, + * entries merge into the plain list. */ + group?: string +} + +/** What a mention provider gets to branch on. Deliberately small — every + * field here is a standing compatibility promise to the plugins using it. */ +export interface ComposerMentionContext { + sessionId: string + /** The profile the live gateway is routed to ('' when detached). */ + gatewayProfile: string +} + +/** Mention providers, memoised against the registry's own stable snapshot — + * resolved once per composer render, not per keystroke. */ +export function useComposerMentionProviders(): ComposerMentionProvider[] { + const contributions = useContributions(COMPOSER_AREAS.mentions) + + return useMemo( + () => contributions.map(c => c.data as ComposerMentionProvider).filter(p => typeof p?.resolve === 'function'), + [contributions] + ) +} 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 index cc1818f43b29..cd6eb48b054d 100644 --- 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 @@ -20,10 +20,12 @@ function gatewayStub(latencyMs = 40) { return { calls, gateway } } -function setup(latencyMs = 40) { +function setup(latencyMs = 40, mentionEntries = []) { const { calls, gateway } = gatewayStub(latencyMs) - const { result } = renderHook(() => useAtCompletions({ gateway: gateway as never, sessionId: 's1', cwd: '/repo' })) + const { result } = renderHook(() => + useAtCompletions({ gateway: gateway as never, sessionId: 's1', cwd: '/repo', mentionEntries }) + ) return { calls, result } } @@ -105,3 +107,79 @@ describe('PERF: @ path completions are cached and skip the debounce', () => { vi.useRealTimers() }) }) + +describe('plugin-contributed mentions ride along the @ picker', () => { + const mentions = [ + { text: '@researcher', display: 'Researcher', meta: 'Model research', group: 'Bots' }, + { text: '@bookie', display: 'Bookie', meta: 'Betting analytics', group: 'Bots' } + ] + + function setupWithMentions(latencyMs = 40) { + const { calls, gateway } = gatewayStub(latencyMs) + + const { result } = renderHook(() => + useAtCompletions({ gateway: gateway as never, sessionId: 's1', cwd: '/repo', mentionEntries: mentions as never[] }) + ) + + return { calls, result } + } + + it('offers matching mentions alongside path completions', async () => { + vi.useFakeTimers() + queryClient.clear() + + const { result } = setupWithMentions() + + await type(result, ['res'], 0) + await act(async () => { + await vi.advanceTimersByTimeAsync(200) + }) + + const items = result.current.adapter.search?.('res') ?? [] + const labels = items.map(i => i.label) + + // Gateway returned @folder:resx/ — the bot mention must ride along. + expect(labels).toContain('Researcher') + expect(labels).not.toContain('Bookie') + expect(labels).toContain('resx/') + + vi.useRealTimers() + }) + + it('filters mentions by their display name too', async () => { + vi.useFakeTimers() + queryClient.clear() + + const { result } = setupWithMentions() + + await type(result, ['book'], 0) + await act(async () => { + await vi.advanceTimersByTimeAsync(200) + }) + + const labels = result.current.adapter.search?.('book')?.map(i => i.label) ?? [] + expect(labels).toContain('Bookie') + expect(labels).not.toContain('Researcher') + + vi.useRealTimers() + }) + + it('tags mention items so the mid-message filter keeps them', async () => { + vi.useFakeTimers() + queryClient.clear() + + const { result } = setupWithMentions() + + await type(result, ['@'], 0) + await act(async () => { + await vi.advanceTimersByTimeAsync(200) + }) + + const items = result.current.adapter.search?.('@') ?? [] + const mentionItem = items.find(i => i.label === 'Researcher') + expect(mentionItem).toBeTruthy() + expect((mentionItem?.metadata as { mention?: boolean } | undefined)?.mention).toBe(true) + + 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 1f8a12df3365..e5ec0acff09e 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 @@ -82,13 +82,15 @@ function classify(entry: CompletionEntry): { } } -/** Live `@` completions backed by the gateway's `complete.path` RPC. */ +/** Live `@` completions backed by the gateway's `complete.path` RPC, plus + * plugin-contributed mention entries (composer.mentions area). */ export function useAtCompletions(options: { gateway: HermesGateway | null sessionId: string | null cwd: string | null + mentionEntries: CompletionEntry[] }): { adapter: Unstable_TriggerAdapter; loading: boolean } { - const { gateway, sessionId, cwd } = options + const { gateway, sessionId, cwd, mentionEntries } = options const enabled = Boolean(gateway) // Cache key: the completion depends on the query AND the directory it's @@ -127,12 +129,24 @@ export function useAtCompletions(options: { const items = result.items ?? [] - return { items: items.length > 0 ? items : starters, query } + // Plugin-contributed mentions ride along whenever their handle/name + // matches what the user is typing. They are grouped so the picker + // reads as one list with a labeled "Bots" (or plugin-chosen) section, + // not a wall of mixed kinds. + const displayOf = (e: CompletionEntry) => (typeof e.display === 'string' ? e.display : e.text) + const mentionMatches = mentionEntries.filter( + e => !query || e.text.toLowerCase().includes(query.toLowerCase()) || displayOf(e).toLowerCase().includes(query.toLowerCase()) + ) + + return { items: items.length > 0 ? [...items, ...mentionMatches] : [...starters, ...mentionMatches], query } } catch { - return { items: starters, query } + return { + items: [...starters, ...mentionEntries.filter(e => !query || e.text.toLowerCase().includes(query.toLowerCase()))], + query + } } }, - [cacheKey, gateway, sessionId, cwd] + [cacheKey, gateway, mentionEntries, sessionId, cwd] ) const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => { @@ -153,7 +167,9 @@ export function useAtCompletions(options: { type: classified.type, label: classified.display, ...(classified.meta ? { description: classified.meta } : {}), - metadata + // Mark plugin-contributed mentions so the mid-message trigger keeps + // them alongside skills (bots are legitimate inline references). + ...(entry.group ? { metadata: { ...metadata, mention: true } } : { metadata }) } }, []) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index c61f81f1fc2c..6d9b6f932823 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -22,6 +22,7 @@ import { $hudMode } from '@/store/hud' import { sessionBlockingPrompt } from '@/store/prompts' import { toggleReview } from '@/store/review' import { $gatewayState } from '@/store/session' +import { $activeGatewayProfile } from '@/store/profile' import { $threadScrolledUp } from '@/store/thread-scroll' import { $autoSpeakReplies } from '@/store/voice-prefs' import { useTheme } from '@/themes' @@ -34,7 +35,7 @@ import { slashArgStage } from './composer-utils' import { ContextMenu } from './context-menu' -import { COMPOSER_AREAS, runComposerMiddleware } from './contrib' +import { COMPOSER_AREAS, runComposerMiddleware, useComposerMentionProviders } from './contrib' import { ComposerControls } from './controls' import { ComposerDirectiveActions } from './directive-actions' import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance' @@ -202,7 +203,25 @@ export function ChatBar({ const composingRef = useRef(false) // true during IME composition (CJK input) const { availableThemes, themeName } = useTheme() - const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null }) + const gatewayProfileName = useStore($activeGatewayProfile) + // Plugin-contributed `@` mentions (e.g. bot roster names) — resolved once + // per composer render, then filtered per keystroke inside useAtCompletions. + const mentionProviders = useComposerMentionProviders() + const mentionEntries = useMemo(() => { + const ctx = { + sessionId: sessionId ?? '', + gatewayProfile: (gatewayProfileName || '').trim() + } + + return mentionProviders.flatMap(provider => { + try { + return provider.resolve(ctx) ?? [] + } catch { + return [] + } + }) + }, [gatewayProfileName, mentionProviders, sessionId]) + const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null, mentionEntries }) const slash = useSlashCompletions({ activeSkin: themeName, gateway: gateway ?? null, skinThemes: availableThemes }) const emoji = useEmojiCompletions() diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx index c54e9ec6f6c9..840b4e8b47de 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx @@ -114,7 +114,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess const [staging, setStaging] = useState(false) const expanded = draft.includes('\n') const canSubmit = draft.trim().length > 0 - const at = useAtCompletions({ cwd, gateway, sessionId }) + const at = useAtCompletions({ cwd, gateway, sessionId, mentionEntries: [] }) const slash = useSlashCompletions({ gateway }) const emoji = useEmojiCompletions() diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 7d13ada9a376..80ded25815f5 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -179,7 +179,13 @@ export const host = { // Every contribution surface, plugin-reachable: register keybinds, palette // commands, routes, themes, panes, composer extensions, and bar items with // the same area ids + payload types core uses. -export { COMPOSER_AREAS, type ComposerAttachmentProvider, type ComposerMiddleware } from '@/app/chat/composer/contrib' +export { + COMPOSER_AREAS, + type ComposerAttachmentProvider, + type ComposerMentionEntry, + type ComposerMentionProvider, + type ComposerMiddleware +} from '@/app/chat/composer/contrib' // -- ui: the design language --------------------------------------------------