From 424d4bdbd4e30cd7dc5b20295a766352afc2e5cc Mon Sep 17 00:00:00 2001 From: Konstantin Khlopkov Date: Fri, 25 Sep 2026 19:52:06 +0300 Subject: [PATCH] fix(desktop): collapse duplicate @-mention rows behind a source's handle claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/desktop/src/app/chat/composer/contrib.ts | 6 +++ .../hooks/use-at-completions-contrib.test.tsx | 38 ++++++++++++++++++ .../chat/composer/hooks/use-at-completions.ts | 39 +++++++++++++++---- .../hermes-bots/plugin.mentions.test.ts | 29 ++++++++++++++ .../src/plugins/hermes-bots/plugin.tsx | 12 +++++- 5 files changed, 116 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/contrib.ts b/apps/desktop/src/app/chat/composer/contrib.ts index 19684e4df4..96c0d5432a 100644 --- a/apps/desktop/src/app/chat/composer/contrib.ts +++ b/apps/desktop/src/app/chat/composer/contrib.ts @@ -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 diff --git a/apps/desktop/src/app/chat/composer/hooks/use-at-completions-contrib.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-at-completions-contrib.test.tsx index d1eeda8565..7477c568b2 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-at-completions-contrib.test.tsx +++ b/apps/desktop/src/app/chat/composer/hooks/use-at-completions-contrib.test.tsx @@ -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' }] : [])) 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 1dc481e818..65fe2557ac 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 @@ -39,7 +39,11 @@ function starterEntries(query: string): CompletionEntry[] { })) } -function mergeCompletionEntries(preferred: CompletionEntry[], fallback: CompletionEntry[]): CompletionEntry[] { +function mergeCompletionEntries( + preferred: CompletionEntry[], + fallback: CompletionEntry[], + claimedHandles?: Set +): CompletionEntry[] { const seenHandles = new Set() 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 } => { const out: CompletionEntry[] = [] + const claimedHandles = new Set() 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 => { 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] diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.mentions.test.ts b/apps/desktop/src/plugins/hermes-bots/plugin.mentions.test.ts index 465026729e..6c32ba0369 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.mentions.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/plugin.mentions.test.ts @@ -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', () => { diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.tsx b/apps/desktop/src/plugins/hermes-bots/plugin.tsx index 1faaff3b38..3c2a68e5d5 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.tsx +++ b/apps/desktop/src/plugins/hermes-bots/plugin.tsx @@ -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}`] }) }) }