fix(desktop): restore mention chip identity icons (#7338)

## Summary

Restore the missing **@ glyph for people and robot icon for agents**
after #7133, and incorporate the requested compact public-key display.

- Wrapping mention chips render their existing bounded icon-bearing
leading fragment.
- Readonly chips show bound keys using the same `8 leading…4 trailing`
formatter as the channel member list: `Scout (150b20bd…15dc)`.
- Full literal labels and exact keys remain authoritative in metadata,
profile targets, title/accessible-name attributes, editor text, saved
bodies and recipient tags. Display abbreviations are never used for
recipient lookup.
- Copy/paste restores the full literal label for a complete compact
chip; partial selections remain plain text. Two keys sharing the same
abbreviation still round-trip to their separate exact recipients.
- No recipient-resolution, authorization, wire-format, composer, or CSS
changes. Existing labels, icons, cloud markers and ordinary mentions
remain intact.

## Verification

Published candidate `9365ab9bc9d9c5d10802580cd576f8e378ff492f`, based on
main `e09f715c9d0ee2cb7bf8a39061e601f3a502f588`:

- **6,444 desktop unit tests passed** on this candidate's final source
tree.
- **44 mock-Chromium tests passed, zero retries in the final run**
across mention recipients, clipboard and cloud provenance: exact
recipient selection, ambiguity rejection, send/edit/reopen, forwarding,
full/partial copy, mismatched-key rejection, matching-abbreviation
collisions and 100%/150% narrow-window geometry.
- TypeScript, desktop Biome/check guards, protected-feature production
build and E2E build passed.
- The compact-renderer regression fails with the formatting call
removed. Original missing-icon and hidden-text accessibility regressions
have red/green evidence.
- Fresh self-review traced rendering, full-key metadata, copy
classifier, paste normalization and identity trust. The same display
formatter owns the accepted compact form on both clipboard sides.

Iteration exposed an existing team-insertion separator flake (passed
final full run) and two new fixture assumptions: non-member sends
require invitation, and Chromium rich paste may retain an NBSP
separator. Tests now exercise invitation and normalize only that
separator when comparing the captured full body and exact tags; no
product change was needed for either.

Earlier local repository-wide `just ci` completed in two invocations
because its initial call hit the ten-minute tool limit during Tauri
compilation. Unchanged native/mobile/backend evidence is reused; the
desktop delta received the full checks above and new remote CI. Native
VoiceOver, real Tauri selection, dark theme and non-Chromium observation
were not performed. Browser artifacts exercise real frontend with mocked
Tauri/relay boundaries, not an installed release.

## Review and visual evidence

Current-head CI and automated review must complete after this update;
the old `2997bfb5` green results do not establish this new head.
Required human review remains separate from agent approvals. No
merge/install/restart authorization.

Before/after icon evidence:
https://github.com/block/buzz/pull/7338#issuecomment-5544575866

Updated compact-key screenshots are posted below. The editor
intentionally retains the full literal address; only readonly chip
display is abbreviated.

Origin:
buzz://message?channel=3355d33a-b72a-423a-b064-a58275f9a8af&id=38b3a27e689f5a9604e273d45f4e3122fceba76081e7bcd3bbdbf439524a5a18

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
Logan Johnson
2026-09-04 16:36:17 -04:00
committed by GitHub
parent 5d107836c6
commit 4afef8649a
15 changed files with 448 additions and 21 deletions

View File

@@ -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");
});

View File

@@ -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";
}

View File

@@ -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) =>
`<span data-mention="" data-mention-pubkey="${key}" ` +
`data-mention-label="${label}">${text}</span>`;
assert.equal(
normalizeMentionClipboardContent(html("Scout (150b20bd…15dc) 2")).text,
`@${label}`,
);
assert.equal(
normalizeMentionClipboardContent(html("Scout (150b20bd…15dc)")).text,
"Scout (150b20bd…15dc)",
);
});

View File

@@ -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

View File

@@ -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(
`<span data-mention="" data-mention-pubkey="${JOHN_SMITH_PUBKEY}" ` +
`data-mention-label="${label}" class="mention-chip">` +
'<span class="inline-chip-leading-fragment">Scout</span> (7c7c7c7c…7c7c) 2</span>',
);
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("…"));
});

View File

@@ -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;

View File

@@ -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})`,
);
});

View File

@@ -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]}`;
}

View File

@@ -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</);
});

View File

@@ -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. */}
<span
className={cn(
"inline-chip-leading-fragment",
inlineChipIconClasses(icon),
)}
>
{displayLabel.slice(0, leadingEnd)}
</span>
{displayLabel.slice(leadingEnd)}
{isAgentMention ? <AgentManagementMarker pubkey={pubkey} /> : null}
</InlineChip>
);

View File

@@ -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=/);
});

View File

@@ -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"),

View File

@@ -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"),

View File

@@ -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] }]);
}
});
}

View File

@@ -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.