fix(desktop): collapse duplicate @-mention rows behind a source's handle claims

The composer's @ popover listed every named profile twice: the Bot Mode
roster tags a bot by the slug of its friendly title (@john for a profile
dir named john-2), while the gateway's own @ completions list the same
backend profile by raw name (@john-2). use-at-completions deduplicated
by display text, so both labels for one routable identity survived and
the merged list truncated arbitrarily at slice(0, 8).

A composer.atCompletions row can now declare `handles` — other @texts
that resolve to the same target. The merge drops a gateway row whose
handle is claimed by a contributed row, keeping the contributed label
(title slug, connection meta). Bot Mode claims the raw profile name for
its local rows only; a remote row never claims a name the local gateway
might list as a different, local bot.
This commit is contained in:
Konstantin Khlopkov
2026-09-25 19:52:06 +03:00
committed by brooklyn!
parent 4d620c977b
commit 424d4bdbd4
5 changed files with 116 additions and 8 deletions

View File

@@ -62,6 +62,12 @@ export interface ComposerAtCompletionItem {
meta?: string
/** Icon slug understood by the completion popover; defaults to 'simple'. */
icon?: string
/** Other `@handle` texts that resolve to the SAME target as `insert` —
* e.g. a bot's raw profile name alongside the title slug it is listed
* under. The `@` popover drops a gateway row whose handle is claimed
* here, so one routable identity is never offered twice under two
* labels. Only claim handles this row really owns. */
handles?: string[]
}
/** Payload of a `composer.atCompletions` data contribution — an extra source

View File

@@ -91,6 +91,44 @@ describe('contributed @ completion sources', () => {
expect(rows.some(label => label.includes('researcher.md'))).toBe(true)
})
it('drops the gateway twin of a row the source claims under another handle', async () => {
vi.useFakeTimers()
// A bot tagged by its title slug (`@john`) is the same routable identity
// the gateway lists by raw profile name (`@john-2`) — one row must
// survive, and it must be the contributed one (title + connection meta).
addSource('bots', q => ('john'.startsWith(q) ? [{ insert: '@john', meta: 'Bot · John ♥', handles: ['@john-2'] }] : []))
const gateway = gatewayStub([
{ text: '@default', display: '@default', meta: 'Windows troubleshooting' },
{ text: '@john-2', display: '@john-2', meta: 'Compañero de noche' },
{ text: '@file:src/john.md', display: 'john.md', meta: 'file' }
])
const { result } = renderHook(() => useAtCompletions({ gateway: gateway as never, sessionId: 's1', cwd: '/repo' }))
const rows = await searchAndRead(result, 'joh')
expect(rows).toContain('@john')
expect(rows.some(label => label.toLowerCase() === '@john-2')).toBe(false)
expect(rows.some(label => label.toLowerCase() === '@default')).toBe(true)
})
it('keeps a same-named gateway row that no source claims', async () => {
vi.useFakeTimers()
// The claim is per-identity: a remote bot listed under its title slug
// must not swallow the LOCAL gateway's same-named profile.
addSource('bots', q => ('cos'.startsWith(q) ? [{ insert: '@cos-bot', meta: 'Bot · CoS Bot · VPS' }] : []))
const gateway = gatewayStub([
{ text: '@default', display: '@default', meta: 'agent profile' },
{ text: '@file:src/a.md', display: 'a.md', meta: 'file' }
])
const { result } = renderHook(() => useAtCompletions({ gateway: gateway as never, sessionId: 's1', cwd: '/repo' }))
const rows = await searchAndRead(result, 'def')
expect(rows.some(label => label.toLowerCase() === '@default')).toBe(true)
})
it('drops rows when the query does not match the source filter', async () => {
vi.useFakeTimers()
addSource('bots', q => ('researcher'.startsWith(q) ? [{ insert: '@researcher', meta: 'Bot' }] : []))

View File

@@ -39,7 +39,11 @@ function starterEntries(query: string): CompletionEntry[] {
}))
}
function mergeCompletionEntries(preferred: CompletionEntry[], fallback: CompletionEntry[]): CompletionEntry[] {
function mergeCompletionEntries(
preferred: CompletionEntry[],
fallback: CompletionEntry[],
claimedHandles?: Set<string>
): CompletionEntry[] {
const seenHandles = new Set<string>()
return [...preferred, ...fallback].filter(entry => {
@@ -49,6 +53,14 @@ function mergeCompletionEntries(preferred: CompletionEntry[], fallback: Completi
return true
}
// A contributed row claimed this handle as another label for a target it
// already lists (a bot's raw profile name under its title slug). The
// gateway's own row for that name is the same routable identity offered
// a second time — keep the contributed row, drop this one.
if (claimedHandles?.has(key)) {
return false
}
if (seenHandles.has(key)) {
return false
}
@@ -125,8 +137,9 @@ export function useAtCompletions(options: {
// one classify/toItem path renders every row. Provider errors are isolated:
// a throwing source drops ITS rows, never the popover.
const contributedEntries = useCallback(
(query: string): CompletionEntry[] => {
(query: string): { entries: CompletionEntry[]; claimedHandles: Set<string> } => {
const out: CompletionEntry[] = []
const claimedHandles = new Set<string>()
for (const contribution of contributed) {
const source = contribution.data as ComposerAtCompletionSource | undefined
@@ -141,6 +154,18 @@ export function useAtCompletions(options: {
continue
}
// Handles a source declares as aliases of its own row are the
// identity it owns: the gateway lists the same target under one
// of them (the raw profile name), and that twin gets dropped in
// the merge below.
for (const handle of item.handles || []) {
const claimed = normalize(handle)
if (claimed) {
claimedHandles.add(claimed)
}
}
out.push({
text: item.insert,
display: item.display || item.insert,
@@ -153,7 +178,7 @@ export function useAtCompletions(options: {
}
}
return out
return { entries: out, claimedHandles }
},
[contributed]
)
@@ -166,10 +191,10 @@ export function useAtCompletions(options: {
const fetcher = useCallback(
async (query: string): Promise<CompletionPayload> => {
const starters = starterEntries(query)
const extras = contributedEntries(query)
const { entries: extras, claimedHandles } = contributedEntries(query)
if (!gateway) {
return { items: mergeCompletionEntries(extras, starters), query }
return { items: mergeCompletionEntries(extras, starters, claimedHandles), query }
}
const word = REF_STARTERS.has(query) ? `@${query}:` : `@${query}`
@@ -196,9 +221,9 @@ export function useAtCompletions(options: {
const items = result.items ?? []
const base = items.length > 0 ? items : starters
return { items: mergeCompletionEntries(extras, base), query }
return { items: mergeCompletionEntries(extras, base, claimedHandles), query }
} catch {
return { items: mergeCompletionEntries(extras, starters), query }
return { items: mergeCompletionEntries(extras, starters, claimedHandles), query }
}
},
[cacheKey, contributedEntries, gateway, sessionId, cwd]

View File

@@ -27,6 +27,7 @@ interface MentionCompletionItem {
display: string
insert: string
meta: string
handles?: string[]
}
interface ComposerDraft {
@@ -366,6 +367,34 @@ describe('@-mention completions', () => {
const result = await handler({ text: '@cos-bot status?' })
expect(result.text).toMatch(/message_agent target: "default@vps"/)
})
it('claims the raw profile name of a local row so the popover drops its gateway twin', async () => {
// `john-2` titled `John ♥` tags as @john; the live gateway lists the same
// backend profile by raw name (@john-2). The contributed row claims that
// name — one row per bot, under the tag the user actually typed.
const { provide } = await contributions({
focused: 'default',
profiles: [
{ name: 'default' },
{ name: 'john-2', ui_meta: { 'hermes-bots': { title: 'John ♥' } } },
{ name: 'eva-2', ui_meta: { 'hermes-bots': { title: 'Eva 🌥' } } }
]
})
const john = provide('john').find(item => item.insert === '@john')
const eva = provide('eva').find(item => item.insert === '@eva')
expect(john?.handles).toEqual(['@john-2'])
expect(eva?.handles).toEqual(['@eva-2'])
})
it('never claims a remote row\'s name — the local gateway\'s twin is a different bot', async () => {
const { provide } = await contributions({ profiles: [{ name: 'default' }, REMOTE_DEFAULTS[0]] })
const contributed = provide('cos').find(item => item.insert === '@cos-bot')
expect(contributed?.handles).toBeUndefined()
})
})
describe('the mention middleware', () => {

View File

@@ -93,6 +93,11 @@ interface MentionCompletionItem {
display: string
insert: string
meta: string
/** Handles that resolve to this same bot — the raw profile name and the
* roster handle — so the popover can drop the gateway's own row for that
* name instead of listing the bot twice (once under its title slug,
* once under the raw name). */
handles?: string[]
}
/** The draft a `composer.middleware` handler rewrites, passes through, or
@@ -190,7 +195,12 @@ export default {
items.push({
insert,
display: insert,
meta: `Bot · ${display}${source}`
meta: `Bot · ${display}${source}`,
// The live gateway's own `@` rows list this backend's profiles
// by raw name; claim ours so the popover drops that twin row.
// Remote rows are NOT ours to claim — the local gateway's
// same-named row resolves locally, not to the remote bot.
...(profile.remoteSource ? {} : { handles: [`@${profile.name}`] })
})
}