From 092c6a7277698bd373ccbc1d008fc1507094ae74 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 10 Sep 2026 15:42:10 -0700 Subject: [PATCH] fix(markdown): align mention chip wrapping (#7501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** fix **User Impact:** Human and agent mentions now break across lines with the same cloned chip treatment as repository and permalink chips while preserving the conversation text rhythm. **Problem:** Profile-backed rendered mentions sat inside an `inline-flex` popover trigger, unlike entity chips, so the wrapper interfered with true inline fragmentation. The browser-layout test measured text-range rows rather than the painted chip rectangles, allowing touching decorations to pass as “separate” fragments. **Solution:** Keep the profile trigger interactive but override its layout to true `inline`, then give mention fragments 18px computed leading inside the message’s 20px prose rhythm. Chromium paints each fragment at 17px and advances it by 20px, leaving a visible gap between cloned rounded rectangles. The browser test now measures the chip’s own `getClientRects()` and asserts fragment count, height, gap, and step; entity links retain their existing 22px leading.
File changes **desktop/src/features/profile/ui/UserProfilePopover.tsx** Allows inline consumers to override the trigger wrapper’s layout without changing other profile-popover call sites. **desktop/src/shared/styles/globals/markdown.css** Keeps one shared wrapping-chip mechanic and gives mention decorations enough room to separate visibly within 20px prose. **desktop/src/shared/ui/markdown.test.mjs** Pins both rendered mentions and entity links to the shared wrapping-chip contract. **desktop/src/shared/ui/markdown/MarkdownMention.tsx** Makes the profile-popover trigger truly inline so the nested mention chip can fragment with surrounding prose. **desktop/src/shared/ui/mentionChip.ts** Keeps `wrapping-inline-chip` as the single contract for fragmenting decorated chips. **desktop/tests/e2e/mentions.spec.ts** Measures the painted chip rectangles, requires a positive fragment gap, and verifies the inline trigger remains mouse- and keyboard-operable. **desktop/tests/e2e/navigation.spec.ts** Keeps a wrapped repository chip as the control, asserting its existing 22px line height and fragment advance.
## Reproduction steps 1. Open a channel in Buzz Desktop using dark theme. 2. Send a message containing a human mention and another containing an agent mention; both chips should remain aligned with adjacent text on a 20px line. 3. Render a collision-qualified mention in a narrow message width; it should break into separately decorated fragments exactly like another wrapping chip, while each fragment follows the 20px prose rhythm. 4. Render a long repository or permalink chip in the same constrained width; it should retain its roomier 22px fragment spacing. ## Screenshot The dark-theme production renderer shows the real qualified label (`bob (npub1hv3…tpuc)`) at an 8rem width. The two lines now paint as visibly separate rounded fragments rather than one continuous rectangle. ![Qualified human mention wrapping into two visibly separate rounded fragments in the dark-theme Buzz timeline](https://github.com/user-attachments/assets/ee6e7be5-937e-4022-9c82-cf71f8203470) ## Validation At commit `2b063e1b4ade30e11f1616269ad4ba4190366885`: - Pre-push desktop gates — file-size check, Biome/checks, typecheck, and 6,483 unit tests passed - `pnpm --dir desktop build` — passed - Focused Playwright coverage for single-line agent mention, single-line human mention, wrapped qualified mention including keyboard profile activation, and timeline mention click — 4 passed - `git diff --check` — passed --------- Signed-off-by: Taylor Ho Co-authored-by: Rizz Co-authored-by: Carl --- .../profile/ui/UserProfilePopover.tsx | 7 +- .../src/shared/styles/globals/markdown.css | 6 + desktop/src/shared/ui/markdown.test.mjs | 1 + .../shared/ui/markdown/MarkdownMention.tsx | 1 + desktop/tests/e2e/mentions.spec.ts | 113 +++++++++++++++++- desktop/tests/e2e/navigation.spec.ts | 27 ++++- 6 files changed, 145 insertions(+), 10 deletions(-) diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index 7edeb286a8..62ec0d4928 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -50,10 +50,9 @@ type UserProfilePopoverProps = { pubkey: string; triggerElement?: "div" | "span"; /** - * Extra classes for the inline-flex trigger wrapper. The wrapper is a flex - * item at most call sites, and its `min-width: auto` refuses to shrink below - * the nowrap width of truncating children — pass `min-w-0 max-w-full` when - * the trigger wraps truncating text so the ellipsis can engage. + * Extra classes for the trigger wrapper, which defaults to inline-flex. + * Use `min-w-0 max-w-full` when truncating flex content must shrink, or + * `inline` when prose content must fragment across lines. */ triggerClassName?: string; /** Accessible name for interactive trigger content that is visually hidden. */ diff --git a/desktop/src/shared/styles/globals/markdown.css b/desktop/src/shared/styles/globals/markdown.css index 251fdb525c..bf6eeb6431 100644 --- a/desktop/src/shared/styles/globals/markdown.css +++ b/desktop/src/shared/styles/globals/markdown.css @@ -100,6 +100,12 @@ white-space: normal; } +/* Mentions use the prose rhythm while retaining cloned chip decoration. */ +.message-markdown [data-mention].wrapping-inline-chip { + padding-block: 0; + line-height: calc(var(--conversation-message-line-height) - 2px); +} + .message-markdown .mention-chip.buzz-link-unavailable, .message-markdown .mention-chip.buzz-link-unavailable:hover, .message-markdown .mention-chip.buzz-link-deleted, diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index 08861a4daa..83af8b7857 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -1400,6 +1400,7 @@ test("resolved human mentions replace the authored at-sign with the shared icon" ); assert.match(html, /data-mention=""/); + assert.match(html, /wrapping-inline-chip/); assert.match( html, /inline-chip-leading-fragment[^>]*inline-chip-icon-human[^>]*>alice<\/span>/, diff --git a/desktop/src/shared/ui/markdown/MarkdownMention.tsx b/desktop/src/shared/ui/markdown/MarkdownMention.tsx index 4928de9513..9d9f59ad60 100644 --- a/desktop/src/shared/ui/markdown/MarkdownMention.tsx +++ b/desktop/src/shared/ui/markdown/MarkdownMention.tsx @@ -77,6 +77,7 @@ export function createMarkdownMention(interactive: boolean) { pubkey={pubkey} role={isAgentMention ? "bot" : undefined} triggerElement="span" + triggerClassName="inline" > {mentionNode} diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index ee3731d16d..40a132e6df 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Locator } from "@playwright/test"; import { truncateNpub } from "../../src/shared/lib/pubkey"; import { waitForAnimations } from "../helpers/animations"; @@ -88,6 +88,43 @@ const JOIN_COLLAPSE_CAPTURE_WIDTH = 560; const JOIN_COLLAPSE_CAPTURE_HEIGHT = 260; const JOIN_COLLAPSE_CAPTURE_VERTICAL_PADDING = 24; +async function timelineChipLayout(chip: Locator) { + return chip.evaluate((element) => { + const paragraph = element.closest("p"); + if (!paragraph) throw new Error("Timeline chip is missing its paragraph"); + const chipBounds = element.getBoundingClientRect(); + const paragraphBounds = paragraph.getBoundingClientRect(); + const chipFragmentRects = Array.from(element.getClientRects()) + .filter((rect) => rect.width > 0 && rect.height > 0) + .sort((a, b) => a.top - b.top); + const chipStyle = getComputedStyle(element); + return { + boxDecorationBreak: + chipStyle.getPropertyValue("box-decoration-break") || + chipStyle.getPropertyValue("-webkit-box-decoration-break"), + chipHeight: chipBounds.height, + chipLineHeight: Number.parseFloat(chipStyle.lineHeight), + fragmentCount: chipFragmentRects.length, + fragmentGap: + chipFragmentRects.length > 1 + ? Math.round(chipFragmentRects[1].top - chipFragmentRects[0].bottom) + : null, + fragmentHeight: + chipFragmentRects.length > 0 + ? Math.round(chipFragmentRects[0].height) + : null, + fragmentStep: + chipFragmentRects.length > 1 + ? Math.round(chipFragmentRects[1].top - chipFragmentRects[0].top) + : null, + paragraphHeight: paragraphBounds.height, + paragraphLineHeight: Number.parseFloat( + getComputedStyle(paragraph).lineHeight, + ), + }; + }); +} + /** Locator scoped to the mention autocomplete dropdown inside the composer. */ function autocomplete(page: import("@playwright/test").Page) { return page @@ -1583,6 +1620,19 @@ test("selecting a persona mention creates a channel agent before sending and sta .locator("[data-mention].agent-mention-highlight", { hasText: "Fizz" }); await expect(mentionChip).toBeVisible(); await expect(mentionChip).toHaveText("Fizz"); + await expect(mentionChip).toHaveClass(/wrapping-inline-chip/); + const timelineLayout = await timelineChipLayout(mentionChip); + expect(timelineLayout).toMatchObject({ + boxDecorationBreak: "clone", + chipHeight: 17, + chipLineHeight: 18, + fragmentCount: 1, + fragmentGap: null, + fragmentHeight: 17, + fragmentStep: null, + paragraphHeight: 20, + paragraphLineHeight: 20, + }); }); test("selecting a persona mention reuses an existing persona agent", async ({ @@ -4518,6 +4568,67 @@ test("mention text is highlighted in sent messages", async ({ page }) => { await expect(mentionChip).toBeVisible(); await expect(mentionChip).toHaveText("bob"); await expect(mentionChip).toHaveClass(/inline-chip-icon-human/); + await expect(mentionChip).toHaveClass(/wrapping-inline-chip/); + + const timelineLayout = await timelineChipLayout(mentionChip); + expect(timelineLayout).toMatchObject({ + boxDecorationBreak: "clone", + chipHeight: 17, + chipLineHeight: 18, + fragmentCount: 1, + fragmentGap: null, + fragmentHeight: 17, + fragmentStep: null, + paragraphHeight: 20, + paragraphLineHeight: 20, + }); +}); + +test("qualified mentions wrap without changing message line rhythm", async ({ + page, +}) => { + const pubkey = TEST_IDENTITIES.bob.pubkey; + const qualifiedLabel = `bob (${pubkey})`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general"); + await emitMockMessage(page, "general", `@${qualifiedLabel}`, { + mentionPubkeys: [pubkey], + }); + await waitForTimelineSettled(page); + + const row = page.getByTestId("message-row").filter({ hasText: "bob" }).last(); + await row.evaluate((element) => { + const prose = element.querySelector(".message-markdown"); + if (!prose) + throw new Error("Qualified mention is missing its prose wrapper"); + prose.style.width = "8rem"; + }); + const mentionChip = row.locator("[data-mention]", { hasText: "bob" }); + await expect(mentionChip).toHaveText(/bob \(npub1hv3…tpuc\)/); + await expect(mentionChip).toHaveClass(/wrapping-inline-chip/); + + const layout = await timelineChipLayout(mentionChip); + expect(layout.boxDecorationBreak).toBe("clone"); + expect(layout.chipLineHeight).toBe(18); + expect(layout.fragmentCount).toBe(2); + expect(layout.fragmentHeight).toBe(17); + expect(layout.fragmentGap).toBeGreaterThanOrEqual(1); + expect(layout.fragmentStep).toBe(20); + expect(layout.paragraphLineHeight).toBe(20); + expect(layout.chipHeight).toBeLessThanOrEqual( + layout.fragmentCount * layout.paragraphLineHeight, + ); + + const trigger = mentionChip.locator("xpath=.."); + await expect(trigger).toHaveCSS("display", "inline"); + await expect(trigger).toHaveAttribute("role", "button"); + await trigger.focus(); + await expect(trigger).toBeFocused(); + await trigger.press("Enter"); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); }); test("clicking author name opens user profile panel", async ({ page }) => { diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index d19849ea17..6f6d1f2840 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -577,21 +577,38 @@ test("composer Buzz chip labels wrap without orphaning their icons", async ({ name: "Open repository relaytoolsobservabilityconsole-main", }); await expect(sentChip).toBeVisible(); + await expect(sentChip).toHaveClass(/wrapping-inline-chip/); await sentChip.evaluate((element) => { const container = element.parentElement; if (container) container.style.width = "220px"; }); - const fragmentRects = await sentChip.evaluate((element) => - Array.from(element.getClientRects(), (rect) => ({ + const fragmentMetrics = await sentChip.evaluate((element) => { + const chipStyle = getComputedStyle(element); + const rects = Array.from(element.getClientRects(), (rect) => ({ bottom: rect.bottom, height: rect.height, left: rect.left, right: rect.right, top: rect.top, width: rect.width, - })).filter((rect) => rect.width > 0 && rect.height > 0), - ); - expect(fragmentRects.length).toBeGreaterThanOrEqual(2); + })).filter((rect) => rect.width > 0 && rect.height > 0); + const fragmentTops = Array.from( + new Set(rects.map((rect) => Math.round(rect.top))), + ).sort((a, b) => a - b); + return { + boxDecorationBreak: + chipStyle.getPropertyValue("box-decoration-break") || + chipStyle.getPropertyValue("-webkit-box-decoration-break"), + fragmentStep: + fragmentTops.length > 1 ? fragmentTops[1] - fragmentTops[0] : null, + lineHeight: Number.parseFloat(chipStyle.lineHeight), + rects, + }; + }); + expect(fragmentMetrics.boxDecorationBreak).toBe("clone"); + expect(fragmentMetrics.lineHeight).toBe(22); + expect(fragmentMetrics.fragmentStep).toBe(22); + expect(fragmentMetrics.rects.length).toBeGreaterThanOrEqual(2); const tooltip = page.getByRole("tooltip"); await sentChip.focus();