mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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.  ## 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:
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>/,
|
||||
|
||||
@@ -77,6 +77,7 @@ export function createMarkdownMention(interactive: boolean) {
|
||||
pubkey={pubkey}
|
||||
role={isAgentMention ? "bot" : undefined}
|
||||
triggerElement="span"
|
||||
triggerClassName="inline"
|
||||
>
|
||||
{mentionNode}
|
||||
</UserProfilePopover>
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user