Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions apps/desktop/src/app/chat/composer/composer-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(' ')
Expand Down
48 changes: 47 additions & 1 deletion apps/desktop/src/app/chat/composer/contrib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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]
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down Expand Up @@ -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()
})
})
28 changes: 22 additions & 6 deletions apps/desktop/src/app/chat/composer/hooks/use-at-completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 => {
Expand All @@ -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 })
}
}, [])

Expand Down
23 changes: 21 additions & 2 deletions apps/desktop/src/app/chat/composer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ 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()

Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 --------------------------------------------------

Expand Down