fix(desktop): resolve overlapping member mentions (#5225)

## Why
Selecting or typing a member whose display name extends another member's
name, such as `@Fast Fizz Codex`, could emit p-tags for both identities
and wake the wrong agent.

## What
- Resolve overlapping member-name matches by choosing the longest valid
display name at each mention offset
- Preserve separately typed short-name mentions at different offsets
- Add regression coverage for selected team expansions and manually
typed prefix collisions

## Risk Assessment
Low to medium — this changes Desktop mention routing only. Exact
mentions and distinct offsets remain supported; same-length ambiguous
display names remain conservatively tagged because text alone cannot
disambiguate them.

Will resolve https://github.com/block/buzz/issues/2909

Generated with Goose

Signed-off-by: Hardworking Honey <c5c455215c2506cb8ba776518cec804af62d3a0526e32d496a22072e395042b9@buzz.block.builderlab.xyz>
Signed-off-by: Atish Patel <atish@squareup.com>
Co-authored-by: Hardworking Honey <c5c455215c2506cb8ba776518cec804af62d3a0526e32d496a22072e395042b9@buzz.block.builderlab.xyz>
This commit is contained in:
Atish Patel
2026-08-10 09:20:54 -05:00
committed by GitHub
parent 119a84897f
commit 44456e200e
4 changed files with 227 additions and 43 deletions

View File

@@ -0,0 +1,91 @@
import { getMentionOffsets } from "./hasMention";
export type MentionPubkeyCandidate = {
displayName: string | null;
isMember: boolean;
pubkey?: string;
};
type MentionMatch = {
displayName: string;
pubkey?: string;
};
function normalizeDisplayName(name: string): string {
return name.trim().toLowerCase();
}
/**
* Returns explicit selected mention pubkeys and manually typed channel-member
* mentions. At each `@` offset, only the longest valid display name wins so a
* member whose name prefixes another member is not spuriously tagged.
*/
export function extractMentionPubkeys({
text,
selectedMentions,
selectedDisplayNames,
memberCandidates,
}: {
text: string;
selectedMentions: ReadonlyMap<string, string>;
selectedDisplayNames?: Iterable<string>;
memberCandidates: readonly MentionPubkeyCandidate[];
}): string[] {
const selectedNames = new Set(
[...selectedMentions.keys(), ...(selectedDisplayNames ?? [])].map(
normalizeDisplayName,
),
);
const matchesByOffset = new Map<number, MentionMatch[]>();
const addMatches = (displayName: string, pubkey?: string) => {
const trimmedName = displayName.trim();
if (!trimmedName) return;
for (const offset of getMentionOffsets(text, trimmedName)) {
const matches = matchesByOffset.get(offset) ?? [];
matches.push({ displayName: trimmedName, pubkey });
matchesByOffset.set(offset, matches);
}
};
for (const [displayName, pubkey] of selectedMentions) {
addMatches(displayName, pubkey);
}
for (const displayName of selectedDisplayNames ?? []) {
addMatches(displayName);
}
for (const candidate of memberCandidates) {
if (
candidate.pubkey &&
candidate.isMember &&
candidate.displayName &&
!selectedNames.has(normalizeDisplayName(candidate.displayName))
) {
addMatches(candidate.displayName, candidate.pubkey);
}
}
const winningPubkeys = new Set<string>();
for (const matches of matchesByOffset.values()) {
const longestNameLength = Math.max(
...matches.map((match) => match.displayName.length),
);
for (const match of matches) {
if (match.pubkey && match.displayName.length === longestNameLength) {
winningPubkeys.add(match.pubkey);
}
}
}
const pubkeys: string[] = [];
for (const [, pubkey] of selectedMentions) {
if (winningPubkeys.delete(pubkey)) pubkeys.push(pubkey);
}
for (const candidate of memberCandidates) {
if (candidate.pubkey && winningPubkeys.delete(candidate.pubkey)) {
pubkeys.push(candidate.pubkey);
}
}
return pubkeys;
}

View File

@@ -140,14 +140,24 @@ function maskMarkdownCode(text: string): string {
*
* Exported separately so it can be unit-tested without importing React.
*/
export function getMentionOffset(text: string, name: string): number | null {
export function getMentionOffsets(text: string, name: string): number[] {
const escaped = escapeRegExp(name);
const pattern = new RegExp(
`(^|\\s|\\(|[*_]{1,3}|\\|\\|)(@${escaped})(?=\\|\\||[\\s,;.!?:)\\]}*_]|$)`,
"i",
"gi",
);
const match = pattern.exec(maskMarkdownCode(text));
return match ? match.index + match[1].length : null;
const maskedText = maskMarkdownCode(text);
const offsets: number[] = [];
let match = pattern.exec(maskedText);
while (match !== null) {
offsets.push(match.index + match[1].length);
match = pattern.exec(maskedText);
}
return offsets;
}
export function getMentionOffset(text: string, name: string): number | null {
return getMentionOffsets(text, name)[0] ?? null;
}
export function hasMention(text: string, name: string): boolean {

View File

@@ -1,7 +1,12 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getMentionOffset, hasMention } from "./hasMention.ts";
import {
getMentionOffset,
getMentionOffsets,
hasMention,
} from "./hasMention.ts";
import { extractMentionPubkeys } from "./extractMentionPubkeys.ts";
// ── Plain @mention ────────────────────────────────────────────────────
@@ -95,6 +100,111 @@ test("does not false-positive on partial name match", () => {
assert.equal(hasMention("@Alice", "Al"), false);
});
test("returns every mention offset", () => {
const text = "@Fast Fizz Codex and @Fast Fizz";
assert.deepEqual(getMentionOffsets(text, "Fast Fizz"), [0, 21]);
});
test("selected longer mention excludes a prefix member at the same offset", () => {
const pubkeys = extractMentionPubkeys({
text: "Hive Codex(@Fast Fizz Codex)",
selectedMentions: new Map([["Fast Fizz Codex", "codex-pubkey"]]),
memberCandidates: [
{
kind: "identity",
pubkey: "fast-fizz-pubkey",
displayName: "Fast Fizz",
isMember: true,
isAgent: true,
},
{
kind: "identity",
pubkey: "codex-pubkey",
displayName: "Fast Fizz Codex",
isMember: true,
isAgent: true,
},
],
});
assert.deepEqual(pubkeys, ["codex-pubkey"]);
});
test("manual longer mention excludes its prefix member", () => {
const pubkeys = extractMentionPubkeys({
text: "@Fast Fizz Codex",
selectedMentions: new Map(),
memberCandidates: [
{
kind: "identity",
pubkey: "fast-fizz-pubkey",
displayName: "Fast Fizz",
isMember: true,
isAgent: true,
},
{
kind: "identity",
pubkey: "codex-pubkey",
displayName: "Fast Fizz Codex",
isMember: true,
isAgent: true,
},
],
});
assert.deepEqual(pubkeys, ["codex-pubkey"]);
});
test("manual prefix mentions choose the longest name at each offset", () => {
const pubkeys = extractMentionPubkeys({
text: "@Fast Fizz Codex, please pair with @Fast Fizz.",
selectedMentions: new Map(),
memberCandidates: [
{
kind: "identity",
pubkey: "fast-fizz-pubkey",
displayName: "Fast Fizz",
isMember: true,
isAgent: true,
},
{
kind: "identity",
pubkey: "codex-pubkey",
displayName: "Fast Fizz Codex",
isMember: true,
isAgent: true,
},
],
});
assert.deepEqual(pubkeys, ["fast-fizz-pubkey", "codex-pubkey"]);
});
test("keeps manually typed prefix member mentions at distinct offsets", () => {
const pubkeys = extractMentionPubkeys({
text: "@Fast Fizz Codex, please pair with @Fast Fizz.",
selectedMentions: new Map([["Fast Fizz Codex", "codex-pubkey"]]),
memberCandidates: [
{
kind: "identity",
pubkey: "fast-fizz-pubkey",
displayName: "Fast Fizz",
isMember: true,
isAgent: true,
},
{
kind: "identity",
pubkey: "codex-pubkey",
displayName: "Fast Fizz Codex",
isMember: true,
isAgent: true,
},
],
});
assert.deepEqual(pubkeys, ["codex-pubkey", "fast-fizz-pubkey"]);
});
// ── Markdown code ─────────────────────────────────────────────────────
test("ignores mentions in inline code", () => {

View File

@@ -40,6 +40,7 @@ import { normalizePubkey } from "@/shared/lib/pubkey";
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
import { flushMentionDebounce } from "./flushMentionDebounce";
import { hasMention } from "./hasMention";
import { extractMentionPubkeys } from "./extractMentionPubkeys";
import { useDraftMentionRouting } from "./useDraftMentionRouting";
import { rankMentionCandidates } from "./mentionRanking";
import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping";
@@ -77,6 +78,7 @@ function appendUniqueName(current: string[], name: string): string[] {
? current
: [...current, name];
}
export function useMentions(
channelId: string | null,
externalMembers?: ChannelMember[],
@@ -791,43 +793,14 @@ 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)];
},
const extractMentionPubkeysForCurrentMentions = React.useCallback(
(text: string): string[] =>
extractMentionPubkeys({
text,
selectedMentions: mentionMapRef.current,
selectedDisplayNames: personaMentionMapRef.current.keys(),
memberCandidates: mentionCandidates,
}),
[mentionCandidates],
);
@@ -972,7 +945,7 @@ export function useMentions(
cancelMentionAutocomplete,
clearMentions,
extractMentionPersonas,
extractMentionPubkeys,
extractMentionPubkeys: extractMentionPubkeysForCurrentMentions,
getDraftMentionRefs,
getMentionDisplayName,
handleMentionKeyDown,