From 12b2068ee4f42d04a44d6b01788e84a553763e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Calexesms=E2=80=9D?= Date: Tue, 28 Jul 2026 00:48:39 -0400 Subject: [PATCH] fix(desktop): preserve trailing agent mentions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: “alexesms” --- .../messages/lib/extractMentionPubkeys.ts | 58 +++++++++++++++++++ .../src/features/messages/lib/hasMention.ts | 13 +++++ .../messages/lib/useMentions.test.mjs | 33 ++++++++++- .../src/features/messages/lib/useMentions.ts | 44 +++----------- desktop/tests/e2e/mentions.spec.ts | 39 +++++++++++++ 5 files changed, 150 insertions(+), 37 deletions(-) create mode 100644 desktop/src/features/messages/lib/extractMentionPubkeys.ts diff --git a/desktop/src/features/messages/lib/extractMentionPubkeys.ts b/desktop/src/features/messages/lib/extractMentionPubkeys.ts new file mode 100644 index 0000000000..53f48372de --- /dev/null +++ b/desktop/src/features/messages/lib/extractMentionPubkeys.ts @@ -0,0 +1,58 @@ +import { hasMention, hasUnambiguousMention } from "./hasMention"; + +type MentionCandidate = { + displayName: string | null; + isAgent?: boolean; + isMember?: boolean; + pubkey?: string; +}; + +export function extractMentionPubkeysFromCandidates( + text: string, + registeredMentions: ReadonlyMap, + registeredPersonaNames: Iterable, + candidates: readonly MentionCandidate[], +): string[] { + const pubkeys: string[] = []; + const selectedDisplayNames = new Set( + [...registeredMentions.keys(), ...registeredPersonaNames].map((name) => + name.trim().toLowerCase(), + ), + ); + const unselectedAgentNames = candidates.flatMap((candidate) => { + const name = candidate.displayName?.trim(); + return candidate.pubkey && + candidate.isAgent && + name && + !selectedDisplayNames.has(name.toLowerCase()) + ? [name] + : []; + }); + + for (const [displayName, pubkey] of registeredMentions) { + if (hasMention(text, displayName)) pubkeys.push(pubkey); + } + + for (const candidate of candidates) { + const name = candidate.displayName; + if (!candidate.pubkey || !name) continue; + if ( + !candidate.isMember && + !( + candidate.isAgent && + hasUnambiguousMention(text, name, unselectedAgentNames) + ) + ) { + continue; + } + if ( + !pubkeys.includes(candidate.pubkey) && + !selectedDisplayNames.has(name.trim().toLowerCase()) && + hasMention(text, name) + ) { + pubkeys.push(candidate.pubkey); + } + } + + return [...new Set(pubkeys)]; +} diff --git a/desktop/src/features/messages/lib/hasMention.ts b/desktop/src/features/messages/lib/hasMention.ts index 5e5bafd8ad..6f53f12647 100644 --- a/desktop/src/features/messages/lib/hasMention.ts +++ b/desktop/src/features/messages/lib/hasMention.ts @@ -153,3 +153,16 @@ export function getMentionOffset(text: string, name: string): number | null { export function hasMention(text: string, name: string): boolean { return getMentionOffset(text, name) !== null; } + +export function hasUnambiguousMention( + text: string, + name: string, + knownNames: readonly string[], +): boolean { + const normalizedName = name.trim().toLowerCase(); + const matchingNames = knownNames.filter( + (candidate) => candidate.trim().toLowerCase() === normalizedName, + ); + + return matchingNames.length === 1 && hasMention(text, name); +} diff --git a/desktop/src/features/messages/lib/useMentions.test.mjs b/desktop/src/features/messages/lib/useMentions.test.mjs index aeb0b7d863..d21712a001 100644 --- a/desktop/src/features/messages/lib/useMentions.test.mjs +++ b/desktop/src/features/messages/lib/useMentions.test.mjs @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { getMentionOffset, hasMention } from "./hasMention.ts"; +import { + getMentionOffset, + hasMention, + hasUnambiguousMention, +} from "./hasMention.ts"; // ── Plain @mention ──────────────────────────────────────────────────── @@ -22,6 +26,33 @@ test("matches @Name at end of string", () => { assert.equal(hasMention("hello @Alice", "Alice"), true); }); +test("resolves one uniquely named agent mention before autocomplete selection", () => { + assert.equal( + hasUnambiguousMention("Can you check this @Bumble", "Bumble", [ + "Bumble", + "Atlas", + ]), + true, + ); + assert.equal( + hasUnambiguousMention("@Bumble, can you check this?", "Bumble", [ + "Bumble", + "Atlas", + ]), + true, + ); +}); + +test("does not guess between agents with the same display name", () => { + assert.equal( + hasUnambiguousMention("Can you check this @Bumble", "Bumble", [ + "Bumble", + "Bumble", + ]), + false, + ); +}); + test("match is case-insensitive", () => { assert.equal(hasMention("@alice", "Alice"), true); assert.equal(hasMention("@ALICE", "Alice"), true); diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b75339..95c7d05cc8 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -37,6 +37,7 @@ import { normalizePubkey } from "@/shared/lib/pubkey"; import { trimMapToSize } from "@/shared/lib/trimMapToSize"; import { flushMentionDebounce } from "./flushMentionDebounce"; import { hasMention } from "./hasMention"; +import { extractMentionPubkeysFromCandidates } from "./extractMentionPubkeys"; import { useDraftMentionRouting } from "./useDraftMentionRouting"; import { rankMentionCandidates } from "./mentionRanking"; import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping"; @@ -792,42 +793,13 @@ export function useMentions( ); const extractMentionPubkeys = React.useCallback( - (text: string): string[] => { - const pubkeys: string[] = []; - const selectedDisplayNames = new Set( - [ - ...mentionMapRef.current.keys(), - ...personaMentionMapRef.current.keys(), - ].map((name) => name.trim().toLowerCase()), - ); - - for (const [displayName, pubkey] of mentionMapRef.current) { - if (hasMention(text, displayName)) { - pubkeys.push(pubkey); - } - } - - for (const candidate of mentionCandidates) { - if (!candidate.pubkey) { - continue; - } - if (!candidate.isMember) { - continue; - } - if (pubkeys.includes(candidate.pubkey)) { - continue; - } - const name = candidate.displayName; - if (name && selectedDisplayNames.has(name.trim().toLowerCase())) { - continue; - } - if (name && hasMention(text, name)) { - pubkeys.push(candidate.pubkey); - } - } - - return [...new Set(pubkeys)]; - }, + (text: string): string[] => + extractMentionPubkeysFromCandidates( + text, + mentionMapRef.current, + personaMentionMapRef.current.keys(), + mentionCandidates, + ), [mentionCandidates], ); diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 694b5abef5..cab81e1302 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -241,6 +241,45 @@ test("@ trigger prioritizes channel members before runnable personas and other m expect(fizzIndex).toBeLessThan(charlieIndex); }); +test("sending an exact agent mention at the end preserves its pubkey tag", async ({ + page, +}) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: TEST_IDENTITIES.charlie.pubkey, + name: "charlie", + status: "running", + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill("Can you check this @charlie"); + await expect(autocomplete(page)).toBeVisible(); + await page.getByTestId("send-message").click(); + + await expect + .poll(() => + page.evaluate(() => { + const events = ( + window as Window & { + __BUZZ_E2E_SIGNED_EVENTS__?: Array<{ + content: string; + tags: string[][]; + }>; + } + ).__BUZZ_E2E_SIGNED_EVENTS__; + return events?.find((event) => event.content.endsWith("@charlie")) + ?.tags; + }), + ) + .toContainEqual(["p", TEST_IDENTITIES.charlie.pubkey]); +}); + test("thread autocomplete keeps multiple long names readable in a narrow panel", async ({ page, }) => {