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
58 changes: 58 additions & 0 deletions desktop/src/features/messages/lib/extractMentionPubkeys.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>,
registeredPersonaNames: Iterable<string>,
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)];
}
13 changes: 13 additions & 0 deletions desktop/src/features/messages/lib/hasMention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
33 changes: 32 additions & 1 deletion desktop/src/features/messages/lib/useMentions.test.mjs
Original file line number Diff line number Diff line change
@@ -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 ────────────────────────────────────────────────────

Expand All @@ -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);
Expand Down
44 changes: 8 additions & 36 deletions desktop/src/features/messages/lib/useMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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],
);

Expand Down
39 changes: 39 additions & 0 deletions desktop/tests/e2e/mentions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}) => {
Expand Down