From 1713af226b30c37b0ba8e9a32d5290b35d87c3fe Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 14:04:02 -0400 Subject: [PATCH 1/3] fix(desktop): restore mention chip identity icons Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src/shared/ui/markdown.test.mjs | 12 +++- .../shared/ui/markdown/MarkdownMention.tsx | 22 ++++++- desktop/tests/e2e/cloud-provenance.spec.ts | 7 +++ desktop/tests/e2e/mention-clipboard.spec.ts | 9 ++- desktop/tests/e2e/mention-recipients.spec.ts | 63 ++++++++++++++++--- 5 files changed, 98 insertions(+), 15 deletions(-) diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index 44d6b16aab7..c80ebcc3133 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -1400,7 +1400,11 @@ 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.match(html, />alice@alice 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.match(html, />alice@alice - {mentionLabel} + {/* Wrapping chips hide the outer icon; keep it with a bounded prefix. */} + + {mentionLabel.slice(leadingEnd)} {isAgentMention ? : null} ); 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..53e8ae0d6bf 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 { @@ -930,7 +933,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..81fc7dc6e7e 100644 --- a/desktop/tests/e2e/mention-recipients.spec.ts +++ b/desktop/tests/e2e/mention-recipients.spec.ts @@ -7,7 +7,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 +17,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 +427,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 +475,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 +497,34 @@ 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"); + 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 +544,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); From 2997bfb5575f8e7d4ed0f69b76455b5266a0558f Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 14:26:16 -0400 Subject: [PATCH 2/3] fix(desktop): expose complete mention labels to assistive technology Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src/shared/ui/markdown.test.mjs | 2 ++ desktop/src/shared/ui/markdown/MarkdownMention.tsx | 1 - desktop/tests/e2e/mention-clipboard.spec.ts | 6 ++++++ desktop/tests/e2e/mention-recipients.spec.ts | 1 + 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index c80ebcc3133..08861a4daa1 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -1405,6 +1405,7 @@ test("resolved human mentions replace the authored at-sign with the shared icon" /inline-chip-leading-fragment[^>]*inline-chip-icon-human[^>]*>alice<\/span>/, ); assert.match(html, /aria-label="alice"/); + assert.doesNotMatch(html, /aria-hidden="true"[^>]*>alicealice@alice /inline-chip-leading-fragment[^>]*inline-chip-icon-agent[^>]*>alice<\/span>/, ); assert.match(html, /aria-label="alice"/); + assert.doesNotMatch(html, /aria-hidden="true"[^>]*>alicealice@alice {/* Wrapping chips hide the outer icon; keep it with a bounded prefix. */} - {mentionLabel.slice(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/mention-recipients.spec.ts b/desktop/tests/e2e/mention-recipients.spec.ts index bedce3fa433..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"; @@ -564,6 +565,13 @@ for (const { kind, scale } of [ .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})`, @@ -865,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); @@ -920,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.