diff --git a/desktop/src/features/messages/lib/mentionClipboard.test.mjs b/desktop/src/features/messages/lib/mentionClipboard.test.mjs
index 4d8f1830aa2..39f16c96791 100644
--- a/desktop/src/features/messages/lib/mentionClipboard.test.mjs
+++ b/desktop/src/features/messages/lib/mentionClipboard.test.mjs
@@ -440,3 +440,25 @@ test("does not treat an uncapped label's ellipsis as a truncation", () => {
// whole must not gain a second accepted form.
assert.equal(matchChipTextToLabel("John…", "John Smith", "@"), "fragment");
});
+
+test("whole compact mention labels are restored only for their declared exact key", () => {
+ const key = `150b20bd${"a".repeat(52)}15dc`;
+ const label = `Scout (${key}) 2`;
+ const compact = "Scout (150b20bd…15dc) 2";
+ assert.equal(matchChipTextToLabel(compact, label, "@", key), "truncated");
+ assert.equal(
+ matchChipTextToLabel(`@${compact}`, label, "@", key),
+ "truncated",
+ );
+ assert.equal(matchChipTextToLabel(compact, label, "@"), "fragment");
+ assert.equal(
+ matchChipTextToLabel(compact, label, "@", "b".repeat(64)),
+ "fragment",
+ );
+ assert.equal(matchChipTextToLabel(compact, label, "#", key), "fragment");
+ assert.equal(
+ matchChipTextToLabel("Scout (150b20bd…15dc)", label, "@", key),
+ "fragment",
+ );
+ assert.equal(matchChipTextToLabel("Scout", label, "@", key), "fragment");
+});
diff --git a/desktop/src/features/messages/lib/mentionClipboard.ts b/desktop/src/features/messages/lib/mentionClipboard.ts
index 19309a9cbb6..8886bf8c29d 100644
--- a/desktop/src/features/messages/lib/mentionClipboard.ts
+++ b/desktop/src/features/messages/lib/mentionClipboard.ts
@@ -1,3 +1,4 @@
+import { formatMentionDisplayLabel } from "@/shared/lib/mentionDisplay";
import { truncateInlineChipLabel } from "@/shared/ui/mentionChip";
import { getMentionOffsets } from "./hasMention";
@@ -77,6 +78,7 @@ export function matchChipTextToLabel(
text: string,
label: string,
sigil: "@" | "#",
+ pubkey?: string,
): ChipTextMatch {
const body = canonicalMentionLabel(text);
const matches = (form: string) => body === form || body === `${sigil}${form}`;
@@ -87,6 +89,13 @@ export function matchChipTextToLabel(
if (truncated !== label && matches(canonicalMentionLabel(truncated))) {
return "truncated";
}
+ // Read-only mentions abbreviate a bound key, never the identity carried by
+ // the clipboard. Restore the full literal label only for the whole display.
+ const compact =
+ sigil === "@" ? formatMentionDisplayLabel(label, pubkey) : label;
+ if (compact !== label && matches(canonicalMentionLabel(compact))) {
+ return "truncated";
+ }
return "fragment";
}
diff --git a/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs b/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs
index 81ccfb4b3df..3e0563ab96c 100644
--- a/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs
+++ b/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs
@@ -265,3 +265,19 @@ test("an ordinary chip still flattens to a registrable mention", () => {
[JOHN_SMITH_PUBKEY],
);
});
+
+test("compact mention paste expands only complete bound labels", () => {
+ const key = `150b20bd${"a".repeat(52)}15dc`;
+ const label = `Scout (${key}) 2`;
+ const html = (text) =>
+ `${text}`;
+ assert.equal(
+ normalizeMentionClipboardContent(html("Scout (150b20bd…15dc) 2")).text,
+ `@${label}`,
+ );
+ assert.equal(
+ normalizeMentionClipboardContent(html("Scout (150b20bd…15dc)")).text,
+ "Scout (150b20bd…15dc)",
+ );
+});
diff --git a/desktop/src/features/messages/lib/normalizeMentionClipboard.ts b/desktop/src/features/messages/lib/normalizeMentionClipboard.ts
index 55472f705c0..1a6f7403b29 100644
--- a/desktop/src/features/messages/lib/normalizeMentionClipboard.ts
+++ b/desktop/src/features/messages/lib/normalizeMentionClipboard.ts
@@ -2,6 +2,7 @@ import {
CHANNEL_LABEL_ATTRIBUTE,
matchChipTextToLabel,
MENTION_LABEL_ATTRIBUTE,
+ MENTION_PUBKEY_ATTRIBUTE,
} from "./mentionClipboard";
/**
@@ -173,7 +174,14 @@ export function normalizeMentionClipboardContent(
// from the same attribute the records carry keeps the two provably
// consistent, whatever the classifier goes on to tolerate.
const match =
- label === null ? "full" : matchChipTextToLabel(text, label, sigil);
+ label === null
+ ? "full"
+ : matchChipTextToLabel(
+ text,
+ label,
+ sigil,
+ el.getAttribute(MENTION_PUBKEY_ATTRIBUTE) ?? undefined,
+ );
span.textContent =
match === "fragment"
? text
diff --git a/desktop/src/features/messages/lib/timelineMentionCopy.test.mjs b/desktop/src/features/messages/lib/timelineMentionCopy.test.mjs
index 2c19dffbedd..4f8c05842aa 100644
--- a/desktop/src/features/messages/lib/timelineMentionCopy.test.mjs
+++ b/desktop/src/features/messages/lib/timelineMentionCopy.test.mjs
@@ -187,3 +187,18 @@ test("copy inlines a blockified chip but preserves its block ancestor", () => {
else delete prototype.innerText;
}
});
+
+test("copy expands compact key text but preserves the exact label and identity", () => {
+ const label = `Scout (${JOHN_SMITH_PUBKEY}) 2`;
+ const flavors = copyRenderedBody(
+ `` +
+ 'Scout (7c7c7c7c…7c7c) 2',
+ );
+ assert.ok(flavors);
+ assert.ok(flavors.html.includes(`@${label}`));
+ assert.ok(
+ flavors.html.includes(`data-mention-pubkey="${JOHN_SMITH_PUBKEY}"`),
+ );
+ assert.ok(!flavors.html.includes("…"));
+});
diff --git a/desktop/src/features/messages/lib/timelineMentionCopy.ts b/desktop/src/features/messages/lib/timelineMentionCopy.ts
index b7c137bc2fd..107360a1141 100644
--- a/desktop/src/features/messages/lib/timelineMentionCopy.ts
+++ b/desktop/src/features/messages/lib/timelineMentionCopy.ts
@@ -50,7 +50,12 @@ function restoreChipSigils(root: HTMLElement): boolean {
const label = element.getAttribute(MENTION_LABEL_ATTRIBUTE);
if (
label &&
- matchChipTextToLabel(element.textContent ?? "", label, "@") !== "fragment"
+ matchChipTextToLabel(
+ element.textContent ?? "",
+ label,
+ "@",
+ element.getAttribute(MENTION_PUBKEY_ATTRIBUTE) ?? undefined,
+ ) !== "fragment"
) {
element.textContent = `@${label}`;
restored = true;
diff --git a/desktop/src/shared/lib/mentionDisplay.test.mjs b/desktop/src/shared/lib/mentionDisplay.test.mjs
new file mode 100644
index 00000000000..20e0f17eb0b
--- /dev/null
+++ b/desktop/src/shared/lib/mentionDisplay.test.mjs
@@ -0,0 +1,46 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { formatMentionDisplayLabel } from "./mentionDisplay.ts";
+import { truncatePubkey } from "./pubkey.ts";
+
+const KEY = `150b20bd${"a".repeat(52)}15dc`;
+
+test("compact mention display uses the member-list formatter and keeps collision suffixes", () => {
+ for (const suffix of ["", " 2", " 10"]) {
+ assert.equal(
+ formatMentionDisplayLabel(`Bad Janet (${KEY})${suffix}`, KEY),
+ `Bad Janet (${truncatePubkey(KEY)})${suffix}`,
+ );
+ }
+ assert.equal(formatMentionDisplayLabel(KEY, KEY), truncatePubkey(KEY));
+ assert.equal(
+ formatMentionDisplayLabel(KEY.toUpperCase(), KEY),
+ truncatePubkey(KEY.toUpperCase()),
+ );
+});
+
+test("display leaves unbound, mismatched, malformed and ordinary labels literal", () => {
+ for (const [label, key] of [
+ [`Bad Janet (${KEY})`, undefined],
+ [`Bad Janet (${KEY})`, "b".repeat(64)],
+ [`Bad Janet (${KEY})`, "bad-key"],
+ [`Bad Janet (${KEY}) 1`, KEY],
+ [`Bad Janet (${KEY}) notes`, KEY],
+ [`Release ${KEY}`, KEY],
+ ["Bad Janet", KEY],
+ ])
+ assert.equal(formatMentionDisplayLabel(label, key), label);
+});
+
+test("matching compact keys do not become identity keys", () => {
+ const other = KEY.replace("aaaa", "bbbb");
+ assert.notEqual(KEY, other);
+ assert.equal(
+ formatMentionDisplayLabel(`Scout (${KEY})`, KEY),
+ formatMentionDisplayLabel(`Scout (${other})`, other),
+ );
+ assert.equal(
+ formatMentionDisplayLabel(`Scout (${KEY})`, other),
+ `Scout (${KEY})`,
+ );
+});
diff --git a/desktop/src/shared/lib/mentionDisplay.ts b/desktop/src/shared/lib/mentionDisplay.ts
new file mode 100644
index 00000000000..10d15643ad4
--- /dev/null
+++ b/desktop/src/shared/lib/mentionDisplay.ts
@@ -0,0 +1,17 @@
+import { truncatePubkey } from "./pubkey";
+
+/** Compact only a bound mention's key; its literal label remains authoritative. */
+export function formatMentionDisplayLabel(
+ label: string,
+ pubkey: string | undefined,
+): string {
+ if (!pubkey || !/^[0-9a-f]{64}$/i.test(pubkey)) return label;
+ if (label.toLowerCase() === pubkey.toLowerCase()) {
+ return truncatePubkey(label);
+ }
+ const qualified = label.match(
+ /^(.*) \(([0-9a-f]{64})\)((?: (?:[2-9]|[1-9][0-9]+))?)$/i,
+ );
+ if (qualified?.[2].toLowerCase() !== pubkey.toLowerCase()) return label;
+ return `${qualified[1]} (${truncatePubkey(qualified[2])})${qualified[3]}`;
+}
diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs
index 44d6b16aab7..08861a4daa1 100644
--- a/desktop/src/shared/ui/markdown.test.mjs
+++ b/desktop/src/shared/ui/markdown.test.mjs
@@ -1400,7 +1400,12 @@ test("resolved human mentions replace the authored at-sign with the shared icon"
);
assert.match(html, /data-mention=""/);
- assert.match(html, /inline-chip-icon-human/);
+ assert.match(
+ html,
+ /inline-chip-leading-fragment[^>]*inline-chip-icon-human[^>]*>alice<\/span>/,
+ );
+ assert.match(html, /aria-label="alice"/);
+ assert.doesNotMatch(html, /aria-hidden="true"[^>]*>alice);
assert.match(html, />alice);
assert.doesNotMatch(html, />@alice);
});
@@ -1432,7 +1437,12 @@ test("agent mentions retain the bot treatment instead of the human icon", () =>
assert.match(html, /data-mention=""/);
assert.match(html, /agent-mention-highlight/);
- assert.match(html, /inline-chip-icon-agent/);
+ assert.match(
+ html,
+ /inline-chip-leading-fragment[^>]*inline-chip-icon-agent[^>]*>alice<\/span>/,
+ );
+ assert.match(html, /aria-label="alice"/);
+ assert.doesNotMatch(html, /aria-hidden="true"[^>]*>alice);
assert.match(html, />alice);
assert.doesNotMatch(html, />@alice);
});
diff --git a/desktop/src/shared/ui/markdown/MarkdownMention.tsx b/desktop/src/shared/ui/markdown/MarkdownMention.tsx
index 0849f5fe250..4928de95137 100644
--- a/desktop/src/shared/ui/markdown/MarkdownMention.tsx
+++ b/desktop/src/shared/ui/markdown/MarkdownMention.tsx
@@ -2,7 +2,12 @@ import type * as React from "react";
import { AgentManagementMarker } from "@/features/agents/ui/OtherSetupAgentMarker";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { cn } from "@/shared/lib/cn";
-import { WRAPPING_INLINE_CHIP_CLASSES } from "@/shared/ui/mentionChip";
+import { formatMentionDisplayLabel } from "@/shared/lib/mentionDisplay";
+import {
+ inlineChipIconClasses,
+ inlineChipLeadingEnd,
+ WRAPPING_INLINE_CHIP_CLASSES,
+} from "@/shared/ui/mentionChip";
import { InlineChip } from "@/shared/ui/InlineChip";
import { useMarkdownRuntime } from "./runtimeContext";
@@ -28,6 +33,9 @@ export function createMarkdownMention(interactive: boolean) {
pubkey !== undefined &&
agentMentionPubkeysByName?.[mentionName] === pubkey;
const mentionLabel = mentionText.replace(/^@/, "");
+ const displayLabel = formatMentionDisplayLabel(mentionLabel, pubkey);
+ const icon = isAgentMention ? "agent" : "human";
+ const leadingEnd = inlineChipLeadingEnd(displayLabel);
// 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.
@@ -46,10 +54,19 @@ export function createMarkdownMention(interactive: boolean) {
)}
title={mentionLabel}
aria-label={mentionLabel}
- icon={isAgentMention ? "agent" : "human"}
+ icon={icon}
interactive={opensProfile}
>
- {mentionLabel}
+ {/* Wrapping chips hide the outer icon; keep it with a bounded prefix. */}
+
+ {displayLabel.slice(0, leadingEnd)}
+
+ {displayLabel.slice(leadingEnd)}
{isAgentMention ? : null}
);
diff --git a/desktop/src/shared/ui/markdownMentionDisplay.test.mjs b/desktop/src/shared/ui/markdownMentionDisplay.test.mjs
new file mode 100644
index 00000000000..bf96caeee00
--- /dev/null
+++ b/desktop/src/shared/ui/markdownMentionDisplay.test.mjs
@@ -0,0 +1,69 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import React from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { truncatePubkey } from "../lib/pubkey.ts";
+import { createMarkdownComponents } from "./markdown.tsx";
+import { renderCachedMarkdown } from "./markdown/nodeCache.ts";
+import { MarkdownRuntimeContext } from "./markdown/runtimeContext.ts";
+
+const KEY = `150b20bd${"a".repeat(52)}15dc`;
+
+for (const agent of [false, true]) {
+ test(`rendered ${agent ? "agent" : "human"} abbreviates a bound key without changing its metadata`, () => {
+ const label = `Scout (${KEY}) 2`;
+ const name = label.toLowerCase();
+ const html = renderToStaticMarkup(
+ React.createElement(
+ MarkdownRuntimeContext.Provider,
+ {
+ value: {
+ channels: [],
+ mentionPubkeysByName: { [name]: KEY },
+ agentMentionPubkeysByName: agent ? { [name]: KEY } : {},
+ },
+ },
+ renderCachedMarkdown({
+ content: `Ask @${label}`,
+ mentionNames: [label],
+ components: createMarkdownComponents(false, false),
+ variant: `compact-mention-${agent}`,
+ }),
+ ),
+ );
+ assert.equal(
+ html.replace(/<[^>]+>/g, ""),
+ `Ask Scout (${truncatePubkey(KEY)}) 2`,
+ );
+ assert.ok(html.includes(`data-mention-label="${label}"`));
+ assert.ok(html.includes(`data-mention-pubkey="${KEY}"`));
+ assert.ok(html.includes(`title="${label}"`));
+ assert.ok(html.includes(`aria-label="${label}"`));
+ assert.match(
+ html,
+ new RegExp(
+ `inline-chip-leading-fragment[^>]*inline-chip-icon-${agent ? "agent" : "human"}`,
+ ),
+ );
+ });
+}
+
+test("an unresolved qualified mention stays literal rather than claiming an abbreviated identity", () => {
+ const label = `Scout (${KEY})`;
+ const html = renderToStaticMarkup(
+ React.createElement(
+ MarkdownRuntimeContext.Provider,
+ {
+ value: { channels: [], mentionPubkeysByName: {} },
+ },
+ renderCachedMarkdown({
+ content: `Ask @${label}`,
+ mentionNames: [label],
+ components: createMarkdownComponents(false, false),
+ variant: "unresolved-compact-mention",
+ }),
+ ),
+ );
+ assert.ok(html.includes(`@${label}`));
+ assert.doesNotMatch(html, /data-mention=/);
+});
diff --git a/desktop/tests/e2e/cloud-provenance.spec.ts b/desktop/tests/e2e/cloud-provenance.spec.ts
index d04b48cf0ab..b8914b491de 100644
--- a/desktop/tests/e2e/cloud-provenance.spec.ts
+++ b/desktop/tests/e2e/cloud-provenance.spec.ts
@@ -139,6 +139,13 @@ for (const agentListDelayMs of [0, 6_000]) {
).toBeVisible();
const chip = article.locator("[data-mention]");
await expect(chip.locator("svg.lucide-cloud")).toBeVisible();
+ const leading = chip.locator(".inline-chip-leading-fragment");
+ await expect(leading).toHaveText("Remot");
+ expect(
+ await leading.evaluate(
+ (element) => getComputedStyle(element, "::before").display,
+ ),
+ ).toBe("block");
await waitForAnimations(page);
await article.screenshot({
path: testInfo.outputPath("cloud-author-chip.png"),
diff --git a/desktop/tests/e2e/mention-clipboard.spec.ts b/desktop/tests/e2e/mention-clipboard.spec.ts
index 3bc4aa28859..19cd599c08e 100644
--- a/desktop/tests/e2e/mention-clipboard.spec.ts
+++ b/desktop/tests/e2e/mention-clipboard.spec.ts
@@ -133,8 +133,11 @@ async function copyFromTimeline(
selection.removeAllRanges();
const range = document.createRange();
if (selectPartialChip) {
- const label = chip.firstChild;
- if (!label) throw new Error("Mention chip has no text node.");
+ const walker = document.createTreeWalker(chip, NodeFilter.SHOW_TEXT);
+ const label = walker.nextNode();
+ if (!label?.nodeValue?.startsWith("John")) {
+ throw new Error("Mention chip has no leading John text node.");
+ }
range.setStart(label, 0);
range.setEnd(label, 4);
} else {
@@ -202,6 +205,11 @@ async function openInboxMentionItem(page: Page) {
},
);
+ const preview = page
+ .getByTestId(`home-inbox-item-${item.id}`)
+ .locator("[data-mention]");
+ await expect(preview).toHaveText("John Smith");
+ expect(await preview.ariaSnapshot()).toContain("John");
await page.getByTestId(`home-inbox-item-${item.id}`).click();
// The inbox resolves a non-member's display name off its own profile batch,
// so wait for the chip's identity rather than for the row — same setup
@@ -210,6 +218,7 @@ async function openInboxMentionItem(page: Page) {
.getByTestId("home-inbox-detail-scroll")
.locator(`[data-mention-pubkey="${JOHN_SMITH_PUBKEY}"]`);
await expect(chip).toHaveText("John Smith", { timeout: 15_000 });
+ expect(await chip.ariaSnapshot()).toContain("John");
return item;
}
@@ -930,7 +939,7 @@ test("a boundary-crossing default copy pastes its chip fragment without a sigil"
return nodes;
};
const label = textNodesUnder(chip).find((node) =>
- node.nodeValue?.includes("John Smith"),
+ node.nodeValue?.includes("Smith"),
);
const tail = textNodesUnder(body).find((node) =>
node.nodeValue?.includes("fixed the bug"),
diff --git a/desktop/tests/e2e/mention-recipients.spec.ts b/desktop/tests/e2e/mention-recipients.spec.ts
index 6c2ebaa565b..0e29ed49327 100644
--- a/desktop/tests/e2e/mention-recipients.spec.ts
+++ b/desktop/tests/e2e/mention-recipients.spec.ts
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test";
+import { truncatePubkey } from "../../src/shared/lib/pubkey";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
@@ -7,7 +8,7 @@ const SECOND = TEST_IDENTITIES.bob.pubkey;
const AMBIGUOUS =
"The mention @Scout is ambiguous. Choose a recipient from the mention picker.";
-async function install(page: Page, channel = "general") {
+async function install(page: Page, channel = "general", agents = false) {
await installMockBridge(page, {
managedAgents:
channel === "watercooler"
@@ -17,10 +18,18 @@ async function install(page: Page, channel = "general") {
status: "running",
channelNames: ["watercooler"],
}))
- : [],
+ : agents
+ ? [FIRST, SECOND].map((pubkey) => ({
+ pubkey,
+ name: "Scout",
+ status: "running",
+ channelNames: [channel],
+ }))
+ : [],
searchProfiles: [FIRST, SECOND].map((pubkey) => ({
pubkey,
displayName: "Scout",
+ isAgent: agents,
})),
});
await page.goto("/");
@@ -419,12 +428,19 @@ test("editing to a longer typed member drops the original shorter reference", as
await expect(row).toContainText("Scout Jones hello");
});
-for (const scale of [1, 1.5]) {
- test(`exact-key chips wrap in narrow composer, sent message and reopen at ${scale}x text`, async ({
+// The default relay directory knows FIRST as an agent; SECOND is a human.
+// The agent variant makes SECOND managed too, covering a qualified bot label.
+for (const { kind, scale } of [
+ { kind: "mixed", scale: 1 },
+ { kind: "mixed", scale: 1.5 },
+ { kind: "agent", scale: 1 },
+ { kind: "agent", scale: 1.5 },
+]) {
+ test(`exact-key ${kind} chips wrap in narrow composer, sent message and reopen at ${scale}x text`, async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 800, height: 900 });
- await install(page);
+ await install(page, "general", kind === "agent");
await page.evaluate((scale) => {
document.documentElement.style.fontSize = `${16 * scale}px`;
}, scale);
@@ -460,6 +476,13 @@ for (const scale of [1, 1.5]) {
host: import("@playwright/test").Locator,
stage: string,
) => {
+ if (stage === "sent") {
+ await expect(host.locator("[data-mention]")).toHaveCount(2);
+ await expect(host.locator("[data-mention]").last()).toHaveAttribute(
+ "data-mention-kind",
+ kind === "agent" ? "agent" : "human",
+ );
+ }
const result = await geometry(host);
expect(result.chips.length).toBeGreaterThanOrEqual(2);
expect(result.scrollWidth).toBeLessThanOrEqual(result.clientWidth + 1);
@@ -475,7 +498,35 @@ for (const scale of [1, 1.5]) {
expect(rect.right).toBeLessThanOrEqual(result.width + 1);
}
}
- if (stage !== "sent") {
+ if (stage === "sent") {
+ for (const chip of await host.locator("[data-mention]").all()) {
+ const expectedKind =
+ kind === "agent" ||
+ (await chip.getAttribute("data-mention-pubkey")) === FIRST
+ ? "agent"
+ : "human";
+ await expect(chip).toHaveAttribute("data-mention-kind", expectedKind);
+ const leading = chip.locator(".inline-chip-leading-fragment");
+ await expect(leading).toHaveText("Scout");
+ expect(await leading.ariaSnapshot()).toContain("Scout");
+ const icon = await leading.evaluate((element) => {
+ const style = getComputedStyle(element, "::before");
+ return {
+ display: style.display,
+ mask: style.maskImage,
+ width: parseFloat(style.width),
+ height: parseFloat(style.height),
+ };
+ });
+ expect(icon.display).toBe("block");
+ expect(icon.mask).toContain("data:image/svg+xml");
+ expect(icon.width).toBeGreaterThan(0);
+ expect(icon.height).toBeGreaterThan(0);
+ await expect(leading).toHaveClass(
+ new RegExp(`inline-chip-icon-${expectedKind}`),
+ );
+ }
+ } else {
for (const prefix of await host
.locator(".mention-prefix-hidden")
.all()) {
@@ -495,7 +546,7 @@ for (const scale of [1, 1.5]) {
});
await waitForAnimations(page);
await page.screenshot({
- path: `test-results/mention-recipients/layout-${scale}-${stage}.png`,
+ path: `test-results/mention-recipients/layout-${kind}-${scale}-${stage}.png`,
});
};
await expect(input).toHaveText(content);
@@ -514,6 +565,13 @@ for (const scale of [1, 1.5]) {
.filter({ hasText: "layout journey" })
.last();
await assertFits(markdown, "sent");
+ const qualifiedChip = row.locator("[data-mention]").last();
+ await expect(qualifiedChip).toHaveText(`Scout (${truncatePubkey(SECOND)})`);
+ await expect(qualifiedChip).toHaveAttribute(
+ "data-mention-label",
+ `Scout (${SECOND})`,
+ );
+ await expect(qualifiedChip).toHaveAttribute("title", `Scout (${SECOND})`);
await expect(row.locator("[data-mention]").last()).toHaveAttribute(
"aria-label",
`Scout (${SECOND})`,
@@ -815,7 +873,7 @@ for (const mismatchedKey of [false, true]) {
.getByTestId("message-row")
.filter({ hasText: "qualified clipboard roundtrip" })
.locator(`[data-mention-pubkey="${SECOND}"]`);
- await expect(chip).toHaveText(`Scout (${SECOND})`);
+ await expect(chip).toHaveText(`Scout (${truncatePubkey(SECOND)})`);
const flavors = await chip.evaluate((element) => {
const range = document.createRange();
range.selectNode(element);
@@ -870,3 +928,118 @@ for (const mismatchedKey of [false, true]) {
.toEqual([mismatchedKey ? [] : [SECOND]]);
});
}
+
+for (const partial of [false, true]) {
+ test(`matching abbreviated keys ${partial ? "do not bind a partial copy" : "retain separate exact recipients through copy and paste"}`, async ({
+ page,
+ }) => {
+ const keys = ["a", "b"].map((middle) => `150b20bd${middle.repeat(52)}15dc`);
+ await installMockBridge(page, {
+ searchProfiles: keys.map((pubkey) => ({ pubkey, displayName: "Scout" })),
+ });
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await page.waitForFunction(() =>
+ window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
+ channelName: "general",
+ }),
+ );
+ await page.evaluate((keys) => {
+ window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
+ channelName: "general",
+ content: `@Scout (${keys[0]}) and @Scout (${keys[1]}) compact collision`,
+ mentionPubkeys: keys,
+ });
+ }, keys);
+ const row = page
+ .getByTestId("message-row")
+ .filter({ hasText: "compact collision" });
+ for (const key of keys) {
+ const chip = row.locator(`[data-mention-pubkey="${key}"]`);
+ await expect(chip).toHaveText(`Scout (${truncatePubkey(key)})`);
+ await expect(chip).toHaveAttribute("title", `Scout (${key})`);
+ const flavors = await chip.evaluate((element, partial) => {
+ const range = document.createRange();
+ range.selectNode(element);
+ if (partial) {
+ const leadingText = document
+ .createTreeWalker(element, NodeFilter.SHOW_TEXT)
+ .nextNode();
+ if (!leadingText) throw new Error("Missing mention text");
+ range.setStart(leadingText, 0);
+ range.setEnd(leadingText, 5);
+ }
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+ const clipboardData = new DataTransfer();
+ const event = new ClipboardEvent("copy", {
+ bubbles: true,
+ cancelable: true,
+ clipboardData,
+ });
+ element.dispatchEvent(event);
+ // Model the browser's default HTML serialization if our handler declines.
+ const fallback = document.createElement("div");
+ fallback.append(range.cloneContents());
+ return {
+ handled: event.defaultPrevented,
+ text: event.defaultPrevented
+ ? clipboardData.getData("text/plain")
+ : (selection?.toString() ?? ""),
+ html: event.defaultPrevented
+ ? clipboardData.getData("text/html")
+ : fallback.innerHTML,
+ };
+ }, partial);
+ expect(flavors.handled).toBe(!partial);
+ expect(flavors.text.trim()).toBe(partial ? "Scout" : `@Scout (${key})`);
+ const input = page.getByTestId("message-input");
+ await input.focus();
+ await input.evaluate((element, flavors) => {
+ const clipboardData = new DataTransfer();
+ clipboardData.setData("text/plain", flavors.text);
+ clipboardData.setData("text/html", flavors.html);
+ element.dispatchEvent(
+ new ClipboardEvent("paste", {
+ bubbles: true,
+ cancelable: true,
+ clipboardData,
+ }),
+ );
+ }, flavors);
+ const marker = ` copied-${keys.indexOf(key)}`;
+ await page.keyboard.type(marker);
+ const content = `${flavors.text}${marker}`;
+ await expect(input).toHaveText(content);
+ await page.getByTestId("send-message").click();
+ if (!partial) {
+ await page
+ .getByRole("alertdialog")
+ .getByRole("button", { name: "Invite", exact: true })
+ .click();
+ }
+ // Chromium can preserve the typed separator as NBSP after a rich paste.
+ // Assert the full literal body and exact tags, tolerating only that space.
+ await expect
+ .poll(() =>
+ page.evaluate(
+ (marker) =>
+ (window.__BUZZ_E2E_SIGNED_EVENTS__ ?? [])
+ .filter(
+ (event) =>
+ event.kind === 9 && event.content.endsWith(marker.trim()),
+ )
+ .map((event) => ({
+ content: event.content.replace(/\u00a0/g, " ").trim(),
+ keys: event.tags
+ .filter((tag) => tag[0] === "p")
+ .map((tag) => tag[1]),
+ })),
+ marker,
+ ),
+ )
+ .toEqual([{ content: content.trim(), keys: partial ? [] : [key] }]);
+ }
+ });
+}
diff --git a/docs/mention-editor.md b/docs/mention-editor.md
index d9a4b54ccb1..9ee6339a7ee 100644
--- a/docs/mention-editor.md
+++ b/docs/mention-editor.md
@@ -117,9 +117,13 @@ Immutable annotated automatic-address metadata remains separate, and only
previously delivered automatic addresses are forwarded. Snapshot bodies and
full-key qualifiers alone never authorize an untagged recipient.
-Full-key literal labels remain intact in the composer and on the wire. Composer
-and rendered mention chips break between characters within narrow line boxes;
-rendered chips expose the complete label through their accessible name/title.
+Full-key literal labels remain intact in the composer and on the wire. Readonly
+mention chips abbreviate only their bound public key with the shared
+`truncatePubkey` display form (eight leading characters, ellipsis, four trailing).
+The complete literal label and exact key remain in metadata, title and profile
+target; whole-chip copy restores the full label for paste/edit round trips.
+Abbreviations are recognition aids, never recipient lookup keys. Partial copies
+remain plain text. Composer and rendered chips still wrap within narrow lines.
The browser regression covers 800px windows at 100% and 150% root text size,
send/reopen, and historical replacement followed by forwarding.