fix(desktop): preserve mention competitors through fallbacks

Compose edit fallback refs before exact occurrence selection. Keep ambiguous historical labels as non-authorizing blockers for rendering and body-based recipient fallbacks; qualified labels only disambiguate when they own an unambiguous occurrence.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
(cherry picked from commit 30545075a876136467c07d82abac83ad1904df23)
Signed-off-by: Logan Johnson <loganj@squareup.com>
(cherry picked from commit df75b5d1ac6d7aaa2bf0dfb4dfaf43b92539249a)
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-08-31 15:41:12 -04:00
parent 6b7b02422e
commit cc096ca2a1
17 changed files with 539 additions and 99 deletions

View File

@@ -121,23 +121,42 @@ export function snapshotDraftMentionRefs(
selectedAgentNames: readonly string[],
memberCandidates: readonly MentionPubkeyCandidate[] = [],
selectedDisplayNames: Iterable<string> = [],
fallbackRefs: readonly DraftMentionRef[] = [],
): DraftMentionRef[] {
// Edit-open refs are fallback bindings, not a second occurrence pass. Compose
// them before matching against current selections, typed members and personas.
const personaLabels = [...selectedDisplayNames];
const currentNames = new Set(
[...mentions.keys(), ...personaLabels].map((name) =>
name.trim().toLowerCase(),
),
);
const fallback = fallbackRefs.filter(
(ref) => !currentNames.has(ref.displayName.trim().toLowerCase()),
);
const bindings = new Map([
...fallback.map((ref) => [ref.displayName, ref.pubkey] as const),
...mentions,
]);
const agentNames = new Set(
selectedAgentNames.map((name) => name.trim().toLowerCase()),
[
...selectedAgentNames,
...fallback.filter((ref) => ref.isAgent).map((ref) => ref.displayName),
].map((name) => name.trim().toLowerCase()),
);
const presentNames = new Set(
mentionOccurrences(
content,
mentionMatchCandidates({
selectedMentions: mentions,
selectedMentions: bindings,
memberCandidates,
selectedDisplayNames,
selectedDisplayNames: personaLabels,
}),
).flatMap((match) =>
match.candidates.map((candidate) => candidate.displayName),
),
);
return [...mentions.entries()]
return [...bindings.entries()]
.filter(([displayName]) => presentNames.has(displayName))
.map(([displayName, pubkey]) => ({
displayName,

View File

@@ -9,9 +9,15 @@ export function getVisibleAgentAddressPubkeys(
body: string,
addressedPubkeys: readonly string[],
mentionPubkeysByName: Readonly<Record<string, string>> | undefined,
mentionNames?: readonly string[],
): string[] {
const inlineMentionPubkeys = new Set(
orderMentionPubkeysByText(body, mentionPubkeysByName, () => true),
orderMentionPubkeysByText(
body,
mentionPubkeysByName,
() => true,
mentionNames,
),
);
return addressedPubkeys.filter(

View File

@@ -0,0 +1,222 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildEditMentionState,
snapshotDraftMentionRefs,
} from "./draftMentionRefs.ts";
import { extractMentionPubkeys } from "./extractMentionPubkeys.ts";
import { getSendToChannelSemantics } from "./sendToChannelSemantics.ts";
import { getVisibleAgentAddressPubkeys } from "./getVisibleAgentAddressPubkeys.ts";
import { resolveMentionProps } from "../../../shared/lib/resolveMentionNames.ts";
import { submitMessageEdit } from "../ui/submitMessageEdit.ts";
const A = "a".repeat(64),
B = "b".repeat(64),
C = "c".repeat(64);
const originalRef = { displayName: "Scout", pubkey: A, isAgent: true };
for (const replacement of ["member", "persona", "selected", "ambiguous"]) {
test(`edit fallback competes with longer ${replacement} before save and restore`, async () => {
const members =
replacement === "member" || replacement === "ambiguous"
? [
{ displayName: "Scout Jones", pubkey: B, isMember: true },
...(replacement === "ambiguous"
? [{ displayName: "Scout Jones", pubkey: C, isMember: true }]
: []),
]
: [];
const bindings = new Map([
["Scout", A],
...(replacement === "selected" ? [["Scout Jones", B]] : []),
]);
const personas = replacement === "persona" ? ["Scout Jones"] : [];
let saved,
restored,
cleared = false,
error;
const options = {
content: "@Scout Jones hello",
originalContent: "@Scout hello",
editTarget: { mentionRefs: [originalRef], unresolvedMentionPubkeys: [] },
editTargetId: "dummy-event",
ownerPubkey: C,
customEmoji: [],
pendingImeta: [],
queuedAttachments: [],
spoileredAttachmentUrls: new Set(),
getMentionRefs: (text, fallback) =>
snapshotDraftMentionRefs(
text,
bindings,
["Scout"],
members,
personas,
fallback,
),
extractMentionPubkeys: (text) =>
extractMentionPubkeys({
text,
selectedMentions: bindings,
selectedDisplayNames: personas,
memberCandidates: members,
}),
clearComposer() {
cleared = true;
},
restoreComposer() {},
restoreMentionRefs(refs) {
restored = refs;
},
revalidateMentionPubkeys: async (keys) => [...keys],
setDeferredUploadPending() {},
shouldRestoreComposer: () => true,
setUploadError(message) {
error = message;
},
save: async (content, tags, notifying) => {
saved = { content, tags, notifying };
},
};
await submitMessageEdit(options);
if (replacement === "ambiguous") {
assert.match(error, /ambiguous/);
assert.equal(cleared, false);
assert.equal(saved, undefined);
return;
}
assert.deepEqual(
saved.tags ?? [],
replacement === "selected" ? [["mention", B]] : [],
);
assert.deepEqual(saved.notifying, replacement === "persona" ? [] : [B]);
await submitMessageEdit({
...options,
save: async () => {
throw new Error("offline");
},
});
assert.deepEqual(
restored.map((ref) => ref.pubkey),
replacement === "selected" ? [B] : [],
);
});
}
test("fallback keeps missing-profile refs but current same-name selection wins", () => {
assert.deepEqual(
snapshotDraftMentionRefs(
"@Scout hello",
new Map(),
[],
[],
[],
[originalRef],
),
[originalRef],
);
assert.deepEqual(
snapshotDraftMentionRefs(
"@Scout hello",
new Map([["Scout", B]]),
[],
[],
[],
[originalRef],
),
[{ displayName: "Scout", pubkey: B, isAgent: false }],
);
assert.deepEqual(
snapshotDraftMentionRefs(
"@Scout Jones hello",
new Map(),
[],
[],
["Scout"],
[originalRef],
),
[],
);
});
for (const prefix of ["Scout", "Scout @Jones"]) {
test(`ambiguous longer alias blocks ${prefix} in hydration and sibling fallbacks`, () => {
const longer = `${prefix} Jones`;
const profiles = {
[A]: { displayName: prefix },
[B]: { displayName: longer },
[C]: { displayName: longer },
};
for (const keys of [
[A, B, C],
[C, B, A],
]) {
const tags = keys.map((key) => ["p", key]);
const text = `@${longer} hello`;
const props = resolveMentionProps(tags, profiles, text);
assert.deepEqual(
buildEditMentionState(text, tags, profiles, () => true).mentionRefs,
[],
);
assert.deepEqual(
new Set(
buildEditMentionState(text, tags, profiles, () => true)
.unresolvedMentionPubkeys,
),
new Set([A, B, C]),
);
assert.deepEqual(
getVisibleAgentAddressPubkeys(
text,
[A],
props.mentionPubkeysByName,
props.mentionNames,
),
[A],
);
assert.deepEqual(
getSendToChannelSemantics(
{ body: text, edited: true, pubkey: "d".repeat(64), tags },
profiles,
).mentionPubkeys,
[],
);
// Blocking one occurrence must not erase a separate valid shorter one.
const mixed = `${text} @${prefix}!`;
assert.deepEqual(
buildEditMentionState(mixed, tags, profiles, () => true).mentionRefs,
[{ displayName: prefix, pubkey: A, isAgent: true }],
);
}
});
}
test("a shadowed or ambiguous qualified alias cannot disambiguate another occurrence", () => {
for (const suffix of [" Jones", ""]) {
const qualified = `Scout (${B})`;
const longer = `${qualified}${suffix}`;
const content = `@Scout and @${longer} hello`;
const state = buildEditMentionState(
content,
[
["p", A],
["p", B],
["p", C],
],
{
[A]: { displayName: "Scout" },
[B]: { displayName: "Scout" },
[C]: { displayName: longer },
},
() => true,
);
assert.equal(
state.mentionRefs.some((ref) => ref.pubkey === A),
false,
);
assert.deepEqual(
state.mentionRefs.map((ref) => ref.pubkey),
suffix ? [C] : [],
);
}
});

View File

@@ -25,3 +25,14 @@ test("dedupes aliases at their earliest authored position", () => {
assert.deepEqual(ordered, [AGENT_A, AGENT_B]);
});
test("case variants cannot overwrite a competing identity before occurrence selection", () => {
assert.deepEqual(
orderMentionPubkeysByText(
"@Scout hello",
{ Scout: AGENT_A, scout: AGENT_B },
() => true,
),
[],
);
});

View File

@@ -5,19 +5,36 @@ export function orderMentionPubkeysByText(
text: string,
mentionPubkeysByName: Readonly<Record<string, string>> | undefined,
isEligible: (pubkey: string) => boolean,
mentionNames: readonly string[] = Object.keys(mentionPubkeysByName ?? {}),
): string[] {
if (!mentionPubkeysByName) return [];
const earliestOffsetByPubkey = new Map<string, number>();
const candidates = Object.entries(mentionPubkeysByName).map(
const bindings = Object.entries(mentionPubkeysByName).map(
([displayName, pubkey]) => ({ displayName, pubkey }),
);
const boundNames = new Set(
bindings.map((item) => item.displayName.toLowerCase()),
);
const candidates = [
...bindings,
...mentionNames
.filter((name) => !boundNames.has(name.toLowerCase()))
.map((displayName) => ({ displayName, pubkey: undefined })),
];
for (const { start, candidates: winners } of mentionOccurrences(
text,
candidates,
)) {
if (new Set(winners.map((item) => normalizePubkey(item.pubkey))).size !== 1)
if (
new Set(
winners.map((item) =>
item.pubkey ? normalizePubkey(item.pubkey) : undefined,
),
).size !== 1
)
continue;
if (!winners[0].pubkey) continue;
const normalized = normalizePubkey(winners[0].pubkey);
if (isEligible(normalized) && !earliestOffsetByPubkey.has(normalized))
earliestOffsetByPubkey.set(normalized, start);

View File

@@ -32,6 +32,11 @@ export function getSendToChannelSemantics(
);
const seenMentions = new Set<string>();
const mentionPubkeys: string[] = [];
const mentionProps = resolveMentionProps(
message.tags,
profiles,
message.body,
);
const effectiveMentionPubkeys = message.edited
? new Set(
(message.tags ?? []).some((tag) => tag[0] === "buzz:mention-snapshot")
@@ -41,9 +46,9 @@ export function getSendToChannelSemantics(
.filter((pubkey) => PUBKEY_PATTERN.test(pubkey))
: orderMentionPubkeysByText(
message.body,
resolveMentionProps(message.tags, profiles, message.body)
.mentionPubkeysByName,
mentionProps.mentionPubkeysByName,
() => true,
mentionProps.mentionNames,
),
)
: null;

View File

@@ -19,17 +19,21 @@ export function useDraftMentionRouting(params: {
setSelectedNames: (names: string[]) => void;
setSelectedAgentNames: (names: string[]) => void;
}): {
getDraftMentionRefs: (content: string) => DraftMentionRef[];
getDraftMentionRefs: (
content: string,
fallbackRefs?: readonly DraftMentionRef[],
) => DraftMentionRef[];
restoreDraftMentionRefs: (refs: readonly DraftMentionRef[]) => void;
} {
const getDraftMentionRefs = React.useCallback(
(content: string) =>
(content: string, fallbackRefs: readonly DraftMentionRef[] = []) =>
snapshotDraftMentionRefs(
content,
params.mentionMapRef.current,
params.selectedAgentNamesRef.current,
params.memberCandidates,
params.personaMentionMapRef.current.keys(),
fallbackRefs,
),
[
params.mentionMapRef,

View File

@@ -1,10 +1,43 @@
import * as React from "react";
import { getAgentAddressMentionPubkeys } from "../lib/agentAddressMention.mjs";
import { getVisibleAgentAddressPubkeys } from "../lib/getVisibleAgentAddressPubkeys";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { truncatePubkey } from "@/shared/lib/pubkey";
import { InlineChip } from "@/shared/ui/InlineChip";
/** Resolve all literal competitors before hiding tag-backed address chips. */
export function useMessageAgentAddressPrefix({
profiles,
body,
tags,
mentionNames,
mentionPubkeysByName,
isKnownAgentPubkey,
}: {
profiles?: UserProfileLookup;
body: string;
tags?: string[][];
mentionNames?: string[];
mentionPubkeysByName?: Record<string, string>;
isKnownAgentPubkey: (pubkey: string) => boolean;
}) {
const pubkeys = React.useMemo(
() =>
getVisibleAgentAddressPubkeys(
body,
getAgentAddressMentionPubkeys(tags).filter(isKnownAgentPubkey),
mentionPubkeysByName,
mentionNames,
),
[body, tags, isKnownAgentPubkey, mentionPubkeysByName, mentionNames],
);
return pubkeys.length > 0 ? (
<MessageAgentAddressPrefix profiles={profiles} pubkeys={pubkeys} />
) : undefined;
}
/** Visible send-state prefix for recipients kept in the composer address tray. */
export function MessageAgentAddressPrefix({
profiles,

View File

@@ -57,9 +57,7 @@ import { MessageTimestamp } from "./MessageTimestamp";
import { SentFromThreadLine } from "./SentFromThreadLine";
import { WaveMessageAttachment } from "./WaveMessageAttachment";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { getAgentAddressMentionPubkeys } from "@/features/messages/lib/agentAddressMention.mjs";
import { getVisibleAgentAddressPubkeys } from "@/features/messages/lib/getVisibleAgentAddressPubkeys";
import { MessageAgentAddressPrefix } from "./MessageAgentAddressPrefix";
import { useMessageAgentAddressPrefix } from "./MessageAgentAddressPrefix";
const DiffMessage = React.lazy(() => import("./DiffMessage"));
const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded"));
export type ThreadDepthGuideAction = {
@@ -276,20 +274,14 @@ export const MessageRow = React.memo(
}
return Object.keys(values).length > 0 ? values : undefined;
}, [isKnownAgentPubkey, mentionPubkeysByName]);
const addressedAgentPubkeys = React.useMemo(() => {
return getVisibleAgentAddressPubkeys(
message.body,
getAgentAddressMentionPubkeys(message.tags).filter(isKnownAgentPubkey),
mentionPubkeysByName,
);
}, [isKnownAgentPubkey, mentionPubkeysByName, message.body, message.tags]);
const agentAddressPrefix =
addressedAgentPubkeys.length > 0 ? (
<MessageAgentAddressPrefix
profiles={profiles}
pubkeys={addressedAgentPubkeys}
/>
) : undefined;
const agentAddressPrefix = useMessageAgentAddressPrefix({
profiles,
body: message.body,
tags: message.tags,
mentionNames,
mentionPubkeysByName,
isKnownAgentPubkey,
});
const imetaByUrl = React.useMemo(
() => (message.tags ? parseImetaTags(message.tags) : undefined),
[message.tags],

View File

@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import { AgentMentionAuthorizationError } from "../lib/agentMentionRevalidation.ts";
import { snapshotDraftMentionRefs } from "../lib/draftMentionRefs.ts";
import { submitMessageEdit } from "./submitMessageEdit.ts";
const UNRESOLVED_USER = "b".repeat(64);
@@ -23,7 +24,8 @@ function baseOptions(
editTarget,
editTargetId: "event-id",
extractMentionPubkeys: () => [],
getMentionRefs: () => [],
getMentionRefs: (text, fallback) =>
snapshotDraftMentionRefs(text, new Map(), [], [], [], fallback),
originalContent: content,
ownerPubkey: "a".repeat(64),
pendingImeta: [],

View File

@@ -4,7 +4,6 @@ import {
} from "@/features/messages/lib/agentMentionRevalidation";
import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore";
import { enqueueBackgroundMediaUpload } from "@/features/messages/lib/backgroundMediaUploadStore";
import { mentionOccurrences } from "@/shared/lib/mentionOccurrences";
import type { DraftMentionRef } from "@/features/messages/lib/useDrafts";
import type { MessageComposerEditTarget } from "@/features/messages/ui/MessageComposer.types";
import {
@@ -33,7 +32,10 @@ type SubmitMessageEditOptions = Omit<
clearComposer: () => void;
customEmoji: ReadonlyArray<CustomEmoji>;
extractMentionPubkeys: (content: string) => string[];
getMentionRefs: (content: string) => DraftMentionRef[];
getMentionRefs: (
content: string,
fallbackRefs: readonly DraftMentionRef[],
) => DraftMentionRef[];
editTargetId: string;
enqueueUpload?: typeof enqueueBackgroundMediaUpload;
editTarget: Pick<
@@ -82,25 +84,9 @@ export async function submitMessageEdit({
setUploadError,
spoileredAttachmentUrls,
}: SubmitMessageEditOptions): Promise<void> {
const selectedRefs = getMentionRefs(content);
const selectedNames = new Set(
selectedRefs.map((ref) => ref.displayName.toLowerCase()),
);
const currentMentionRefs = [
...selectedRefs,
...(editTarget.mentionRefs ?? []).filter(
(ref) => !selectedNames.has(ref.displayName.toLowerCase()),
),
];
const draft: EditDraft = {
content,
mentionRefs: [
...new Set(
mentionOccurrences(content, currentMentionRefs).flatMap(
(match) => match.candidates,
),
),
],
mentionRefs: getMentionRefs(content, editTarget.mentionRefs ?? []),
pendingImeta: [...pendingImeta],
queuedAttachments: [...queuedAttachments],
spoileredAttachmentUrls: new Set(spoileredAttachmentUrls),

View File

@@ -47,6 +47,7 @@ function collectProfileAliases(
}
export type ResolvedMentionProps = {
/** All literal competitors, including ambiguous aliases without a binding. */
mentionNames: string[] | undefined;
mentionPubkeysByName: Record<string, string> | undefined;
};
@@ -59,8 +60,9 @@ export type ResolvedMentionProps = {
* `p` tags drive notification/search semantics. `mention` tags only preserve
* render metadata for reference-only mentions.
*
* Both outputs come from the same alias set, so any `@name` chip the markdown
* renderer matches is guaranteed to resolve to a pubkey.
* Recognition is separate from identity resolution: ambiguous aliases still
* own their literal ranges but have no map entry. An explicit (possibly empty)
* map tells renderers to leave those unbound occurrences as plain text.
*/
export function resolveMentionProps(
tags: string[][] | undefined,
@@ -110,6 +112,18 @@ export function resolveMentionProps(
add(displayName, pubkey);
}
// Qualification can narrow an ambiguous base only when that literal is itself
// an unambiguous winning occurrence, not a prefix of another tagged alias.
const ownedAliases = new Set(
mentionOccurrences(content, [...aliases.values()]).flatMap((match) =>
match.candidates
.filter((entry) => entry.keys.size === 1)
.map((entry) => entry.displayName.toLowerCase()),
),
);
const ownedQualified = qualified.filter((item) =>
ownedAliases.has(item.displayName.toLowerCase()),
);
const names = new Set<string>();
const pubkeysByName: Record<string, string> = {};
for (const [label, entry] of aliases) {
@@ -119,18 +133,18 @@ export function resolveMentionProps(
// Scout can own the unqualified label; tag iteration order is irrelevant.
keys = keys.filter(
(key) =>
!qualified.some((item) => item.base === label && item.pubkey === key),
!ownedQualified.some(
(item) => item.base === label && item.pubkey === key,
),
);
}
if (keys.length !== 1) continue;
names.add(entry.displayName);
pubkeysByName[label] = keys[0];
if (keys.length === 1) pubkeysByName[label] = keys[0];
}
return {
mentionNames: names.size > 0 ? [...names] : undefined,
mentionPubkeysByName:
Object.keys(pubkeysByName).length > 0 ? pubkeysByName : undefined,
mentionPubkeysByName: names.size > 0 ? pubkeysByName : undefined,
};
}

View File

@@ -1474,3 +1474,53 @@ test("renderEntityLinkAnchor keeps chip styling when interaction is disabled", (
assert.match(html, /class="mention-chip\s/);
assert.doesNotMatch(html, /<button/);
});
test("ambiguous longer aliases stay literal rather than rendering a shorter tagged chip", async () => {
const { resolveMentionProps } = await import("../lib/resolveMentionNames.ts");
const a = "a".repeat(64),
b = "b".repeat(64),
c = "c".repeat(64);
for (const includeShorter of [false, true]) {
const content = `@Scout Jones hello${includeShorter ? " @Scout!" : ""}`;
const props = resolveMentionProps(
[
["p", a],
["p", b],
["p", c],
],
{
[a]: { displayName: "Scout" },
[b]: { displayName: "Scout Jones" },
[c]: { displayName: "Scout Jones" },
},
content,
);
const markdown = renderCachedMarkdown({
components: createMarkdownComponents(false, false),
content,
mentionNames: props.mentionNames,
variant: "ambiguous-prefix-test",
});
const html = renderToStaticMarkup(
React.createElement(
MarkdownRuntimeContext.Provider,
{
value: {
channels: [],
mentionPubkeysByName: props.mentionPubkeysByName,
onOpenChannel() {},
onOpenEntityLink() {},
onOpenMessageLink() {},
relayOrigin: null,
},
},
markdown,
),
);
assert.match(html, /@Scout Jones hello/);
assert.equal(
(html.match(/data-mention=""/g) ?? []).length,
includeShorter ? 1 : 0,
);
}
});

View File

@@ -13,8 +13,8 @@ import {
resolveMessageLinkRenderTarget,
type ParsedMessageLink,
} from "@/features/messages/lib/messageLink";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { renderAudioMessageAttachment } from "@/features/messages/ui/AudioMessageAttachment";
import { invokeTauri } from "@/shared/api/tauri";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { cn } from "@/shared/lib/cn";
import { parseEntityLink } from "@/shared/lib/entityLink";
@@ -23,7 +23,7 @@ import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { useRelayOrigin } from "@/shared/lib/useRelayOrigin";
import { AttachmentGroup } from "@/shared/ui/attachment";
import { ConfigNudgeCard } from "@/shared/ui/config-nudge-attachment";
import { InlineChip } from "@/shared/ui/InlineChip";
import { createMarkdownMention } from "./markdown/MarkdownMention";
import { LinkPreviewList } from "@/shared/ui/link-preview-list";
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
import {
@@ -1576,48 +1576,7 @@ export function createMarkdownComponents(
ul: ({ children }) => (
<ul className={cn("list-disc", listClassName)}>{children}</ul>
),
mention: function MarkdownMention({
children,
}: {
children?: React.ReactNode;
}) {
const { agentMentionPubkeysByName, mentionPubkeysByName } =
useMarkdownRuntime();
const mentionText = String(children ?? "");
const mentionName = mentionText.replace(/^@/, "").trim().toLowerCase();
const pubkey = mentionPubkeysByName?.[mentionName];
const isAgentMention =
pubkey !== undefined &&
agentMentionPubkeysByName?.[mentionName] === pubkey;
const mentionLabel = mentionText.replace(/^@/, "");
// Only chips that actually open a profile get the clickable affordance.
// A mention whose pubkey didn't resolve stays a plain chip — a pointer
// cursor there promises a click that does nothing.
const opensProfile = interactive && pubkey !== undefined;
const mentionNode = (
<InlineChip
data-mention=""
className={cn(isAgentMention && "agent-mention-highlight")}
icon={isAgentMention ? "agent" : "human"}
interactive={opensProfile}
>
{mentionLabel}
</InlineChip>
);
return opensProfile ? (
<UserProfilePopover
botIdenticonValue={mentionLabel}
pubkey={pubkey}
role={isAgentMention ? "bot" : undefined}
triggerElement="span"
>
{mentionNode}
</UserProfilePopover>
) : (
mentionNode
);
},
mention: createMarkdownMention(interactive),
emoji: ({ src, alt }: { src?: string; alt?: string }) => {
const resolvedSrc = src ? rewriteRelayUrl(src) : src;
if (!resolvedSrc) {

View File

@@ -0,0 +1,53 @@
import type * as React from "react";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { cn } from "@/shared/lib/cn";
import { InlineChip } from "@/shared/ui/InlineChip";
import { useMarkdownRuntime } from "./runtimeContext";
/** Bind interactivity once; names and identity resolution remain runtime inputs. */
export function createMarkdownMention(interactive: boolean) {
return function MarkdownMention({
children,
}: {
children?: React.ReactNode;
}) {
const { agentMentionPubkeysByName, mentionPubkeysByName } =
useMarkdownRuntime();
const mentionText = String(children ?? "");
const mentionName = mentionText.replace(/^@/, "").trim().toLowerCase();
const pubkey = mentionPubkeysByName?.[mentionName];
// Unbound literal competitors consume their full range, without a chip.
if (mentionPubkeysByName && !pubkey) return mentionText;
const isAgentMention =
pubkey !== undefined &&
agentMentionPubkeysByName?.[mentionName] === pubkey;
const mentionLabel = mentionText.replace(/^@/, "");
// Only chips that actually open a profile get the clickable affordance.
// A mention whose pubkey didn't resolve stays a plain chip — a pointer
// cursor there promises a click that does nothing.
const opensProfile = interactive && pubkey !== undefined;
const mentionNode = (
<InlineChip
data-mention=""
className={cn(isAgentMention && "agent-mention-highlight")}
icon={isAgentMention ? "agent" : "human"}
interactive={opensProfile}
>
{mentionLabel}
</InlineChip>
);
return opensProfile ? (
<UserProfilePopover
botIdenticonValue={mentionLabel}
pubkey={pubkey}
role={isAgentMention ? "bot" : undefined}
triggerElement="span"
>
{mentionNode}
</UserProfilePopover>
) : (
mentionNode
);
};
}

View File

@@ -274,3 +274,59 @@ test("selected duplicate labels survive send, reopen, replacement and second reo
.poll(() => editPayload(secondReplacement))
.toEqual({ references: [SECOND], notifying: [] });
});
test("editing to a longer typed member drops the original shorter reference", async ({
page,
}) => {
await installMockBridge(page, {
searchProfiles: [
{ pubkey: FIRST, displayName: "Scout" },
{ pubkey: SECOND, displayName: "Scout Jones" },
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
const input = page.getByTestId("message-input");
await input.fill("@Scout");
await page.getByTestId(`mention-suggestion-${FIRST}`).click();
await page.keyboard.type("hello");
await page.getByTestId("send-message").click();
await expect.poll(() => recipients(page, "@Scout hello")).toEqual([[FIRST]]);
const row = page
.getByTestId("message-timeline")
.getByTestId("message-row")
.last();
await expect(row).toContainText("Scout hello");
await waitForAnimations(page);
await row.hover();
await row.getByRole("button", { name: "More actions" }).click();
await page.getByRole("menuitem", { name: "Edit message" }).click();
await expect(input).toHaveText("@Scout hello");
await input.fill("@Scout Jones hello");
await page.getByTestId("send-message").click();
await expect
.poll(() =>
page.evaluate(() => {
const call = window.__BUZZ_E2E_COMMAND_LOG__
?.filter((call) => call.command === "edit_message")
.at(-1);
const input = (
call?.payload as {
input?: {
content: string;
mentionTags?: string[][];
mentionPubkeys: string[];
};
}
)?.input;
return input?.content === "@Scout Jones hello"
? {
references: input.mentionTags ?? [],
notifying: input.mentionPubkeys,
}
: null;
}),
)
.toEqual({ references: [], notifying: [SECOND] });
await expect(row).toContainText("Scout Jones hello");
});

View File

@@ -52,3 +52,14 @@ profile renames or missing profiles. Historical arbitrary labels are not stored:
missing or genuinely ambiguous unqualified aliases remain unresolved,
non-notifying references rather than guessed recipients. Edit regression coverage
checks reference preservation, not a claim of new notifying edit `p` tags.
Occurrence recognition must retain every competing literal label before binding
eligibility is applied. The historical resolver's `mentionNames` includes
ambiguous aliases as blockers; its explicit `mentionPubkeysByName` map binds only
resolved aliases. Renderers leave unbound occurrences as literal text. Body-based
ordering and legacy send-to-channel fallback use both outputs, not the map alone.
Edit-open fallback refs enter the same candidate composition as current selected
refs, typed members and personas before presence selection; they are never matched
in a separate, narrower pass. Current selections (including unbound personas)
take precedence over same-label fallback refs. Historical unresolved identities
still preserve non-notifying metadata, without claiming a literal binding.