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:
committed by
brooklyn!
parent
4d620c977b
commit
424d4bdbd4
@@ -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
|
||||
|
||||
@@ -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' }] : []))
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}`] })
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user