fix(markdown): align mention chip wrapping (#7501)

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

<details>
<summary>File changes</summary>

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

</details>

## 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 <taylorkmho@gmail.com>
Co-authored-by: Rizz <rizz@agents.buzz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
This commit is contained in:
Taylor Ho
2026-09-10 15:42:10 -07:00
committed by GitHub
parent 00209076c7
commit 092c6a7277
6 changed files with 145 additions and 10 deletions

View File

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

View File

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

View File

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

View File

@@ -77,6 +77,7 @@ export function createMarkdownMention(interactive: boolean) {
pubkey={pubkey}
role={isAgentMention ? "bot" : undefined}
triggerElement="span"
triggerClassName="inline"
>
{mentionNode}
</UserProfilePopover>

View File

@@ -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<HTMLElement>(".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 }) => {

View File

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