fix(composer): wrap Buzz chip labels without orphaning icons (#6581)

**Category:** fix
**User Impact:** Long Buzz link chips now wrap without overflowing in
composers and sent messages; icon-bearing stubs use at most five
graphemes when no earlier separator exists, so some sent chips attach
the icon to a shorter prefix than before. Labels over 48 graphemes are
visibly truncated in the composer while their full identity remains
available to assistive technology and in the tooltip.

**Problem:** Long repository, issue, pull request, and channel chip
labels could orphan their icon or overflow narrow composers and sent
messages. **Solution:** Keep the icon with a bounded, grapheme-safe
leading fragment while allowing the remaining text to break anywhere;
cap visible labels at 48 graphemes without changing the full tooltip or
accessible identity.

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

**desktop/src/features/messages/lib/composerMessageLinkNode.ts**
Splits composer chip content into an icon-bearing leading fragment and a
freely wrapping remainder while preserving the semantic label and link
metadata.

**desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs**
Updates renderer assertions for the fragment structure and verifies
every supported Buzz link kind retains the intended visible label.

**desktop/src/shared/styles/globals/composer.css**
Keeps ordinary composer mention decorations inline while relying on the
existing shared markdown chip wrapping rules for Buzz links.

**desktop/src/shared/ui/mentionChip.ts**
Centralizes grapheme-aware leading-fragment boundaries and label
truncation so composer and sent chips share the same visible identity.

**desktop/src/shared/ui/markdown/BuzzLinkChip.tsx**
Uses the shared grapheme-aware boundary when rendering sent-message chip
fragments.

**desktop/tests/e2e/navigation.spec.ts**
Covers increasing wrap depth across constrained widths, icon attachment,
the sent-message wrap, accessible labeling, and tooltip positioning over
both edge fragments.

</details>

## Reproduction steps

1. Open a desktop channel and paste a Buzz link with a long repository
or channel name into the composer.
2. Narrow the composer until the chip spans two or more lines.
3. Confirm the label breaks mid-string while the icon remains attached
to the first label fragment.
4. Send the message, hover both the first and last rendered fragments,
and confirm the tooltip follows the hovered fragment.

## Screenshots

**Before — the icon drops onto a separate line from its chip label**

![Composer chip with an orphaned icon before the
fix](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6581/00-before-inline-chip-icon-wrap.png)

**After — the icon stays attached while the remaining label wraps**

The same long repository chip at three composer widths. Its label gains
line breaks as space contracts, while the icon remains attached to the
leading fragment.

**420px — one line**

![Long repository chip on one line at
420px](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6581/01-wide-420px.png)

**210px — two lines**

![Long repository chip on two lines at
210px](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6581/02-medium-210px.png)

**150px — three lines**

![Long repository chip on three lines at
150px](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6581/03-narrow-150px.png)

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
This commit is contained in:
Taylor Ho
2026-08-24 14:55:05 -07:00
committed by GitHub
parent f6e6617a9d
commit f79d346a17
10 changed files with 545 additions and 37 deletions

View File

@@ -19,6 +19,7 @@ const CHANNEL_MESSAGE_ID = "a".repeat(64);
const CHANNEL_MESSAGE_HREF = `buzz://channel/${CHANNEL_ID}/${CHANNEL_MESSAGE_ID}`;
const OWNER = "a".repeat(64);
const REPO_HREF = `buzz://repo?owner=${OWNER}&d=buzz-world`;
const PROJECT_HREF = `buzz://project?owner=${OWNER}&d=buzz-world`;
const ISSUE_ID = "b".repeat(64);
const ISSUE_HREF = `buzz://issue?id=${ISSUE_ID}&owner=${OWNER}&d=buzz-world`;
const PR_ID = "c".repeat(64);
@@ -188,6 +189,10 @@ test("markdown parsing stops message links before emphasis delimiters", () => {
assert.deepEqual(token.meta, { channelName: "general", href: HREF });
});
function renderedChipLabel(rendered) {
return `${rendered[2][2]}${rendered[3]}`;
}
test("composer node uses the sent-message chip presentation", () => {
const node = {
attrs: { channelName: "general", href: HREF },
@@ -209,7 +214,44 @@ test("composer node uses the sent-message chip presentation", () => {
assert.equal(rendered[1]["data-buzz-link"], "");
// Channel label only — no event hash, so the chip does not change width when
// the draft is sent and the rendered chip resolves its metadata.
assert.equal(rendered[2], "general");
assert.match(rendered[1].class, /wrapping-inline-chip/);
assert.match(rendered[2][1].class, /inline-chip-leading-fragment/);
assert.equal(renderedChipLabel(rendered), "general");
});
test("composer node truncates and preserves grapheme-safe leading fragments", () => {
const render = ComposerMessageLinkNode.configure({
resolveChannelName: () => undefined,
}).config.renderHTML;
assert.ok(render);
const longName = `relay-${"observability".repeat(5)}`;
const longRendered = render.call(
{ options: { resolveChannelName: () => undefined } },
{
node: { attrs: { channelName: longName, href: HREF } },
HTMLAttributes: {},
},
);
assert.equal(renderedChipLabel(longRendered), `${longName.slice(0, 47)}…`);
for (const [label, expectedLeading] of [
["🇺🇸channel", "🇺🇸chan"],
["e\u0301quipe", "e\u0301quip"],
["relaytoolsobservabilityconsole-main", "relay"],
[" leading-space", ""],
]) {
const rendered = render.call(
{ options: { resolveChannelName: () => undefined } },
{
node: { attrs: { channelName: label, href: HREF } },
HTMLAttributes: {},
},
);
assert.equal(rendered[2][2], expectedLeading);
assert.match(rendered[2][1].class, /inline-chip-with-icon/);
assert.equal(renderedChipLabel(rendered), label);
}
});
test("composer node renders channel and entity chip presentations", () => {
@@ -227,24 +269,29 @@ test("composer node renders channel and entity chip presentations", () => {
const channel = render(CHANNEL_HREF);
assert.equal(channel[1]["data-channel-deep-link"], "");
assert.match(channel[1].class, /inline-chip-icon-channel/);
assert.equal(channel[2], "general");
assert.equal(renderedChipLabel(channel), "general");
const repo = render(REPO_HREF);
assert.equal(repo[1]["data-buzz-link-kind"], "repo");
assert.match(repo[1].class, /inline-chip-icon-repo/);
assert.equal(repo[2], "buzz-world");
assert.equal(renderedChipLabel(repo), "buzz-world");
const project = render(PROJECT_HREF);
assert.equal(project[1]["data-buzz-link-kind"], "project");
assert.match(project[1].class, /inline-chip-icon-project/);
assert.equal(renderedChipLabel(project), "buzz-world");
const issue = render(ISSUE_HREF);
assert.equal(issue[1]["data-buzz-link-kind"], "issue");
assert.match(issue[1].class, /inline-chip-icon-issue/);
// Repository name only — the rendered chip never widens into the issue
// title, so the composer must not widen into the event hash either.
assert.equal(issue[2], "buzz-world");
assert.equal(renderedChipLabel(issue), "buzz-world");
const pullRequest = render(PR_HREF);
assert.equal(pullRequest[1]["data-buzz-link-kind"], "pr");
assert.match(pullRequest[1].class, /inline-chip-icon-pr/);
assert.equal(pullRequest[2], "buzz-world");
assert.equal(renderedChipLabel(pullRequest), "buzz-world");
});
test("markdown rendering stores identity in attributes, not visible id text", () => {

View File

@@ -12,8 +12,11 @@ import {
} from "@/shared/lib/entityLink";
import {
inlineChipIconClasses,
inlineChipLeadingEnd,
type InlineChipIconKind,
MENTION_CHIP_BASE_CLASSES,
truncateInlineChipLabel,
WRAPPING_INLINE_CHIP_CLASSES,
} from "@/shared/ui/mentionChip";
import { buildChannelLink, parseChannelLink } from "./channelLink";
import { getMessageLinkLabel } from "./messageLinkLabel";
@@ -285,6 +288,38 @@ function composerLinkPresentation(
};
}
function wrappingComposerChipContent(
label: string,
icon: InlineChipIconKind,
): { leading: [string, Record<string, string>, string]; remainder: string } {
const leadingEnd = inlineChipLeadingEnd(label);
if (!leadingEnd) {
return {
leading: [
"span",
{
"aria-hidden": "true",
class: `inline-chip-leading-fragment ${inlineChipIconClasses(icon)}`,
},
"",
],
remainder: label,
};
}
return {
leading: [
"span",
{
"aria-hidden": "true",
class: `inline-chip-leading-fragment ${inlineChipIconClasses(icon)}`,
},
label.slice(0, leadingEnd),
],
remainder: label.slice(leadingEnd),
};
}
export const ComposerMessageLinkNode =
Node.create<ComposerMessageLinkNodeOptions>({
name: COMPOSER_MESSAGE_LINK_NODE_NAME,
@@ -328,11 +363,16 @@ export const ComposerMessageLinkNode =
String(node.attrs.channelName ?? ""),
this.options.resolveChannelName,
);
const visibleLabel = truncateInlineChipLabel(presentation.label);
const content = wrappingComposerChipContent(
visibleLabel,
presentation.icon,
);
return [
"span",
mergeAttributes(HTMLAttributes, {
"aria-label": presentation.ariaLabel,
class: `${MENTION_CHIP_BASE_CLASSES} ${inlineChipIconClasses(presentation.icon)} cursor-text`,
class: `${MENTION_CHIP_BASE_CLASSES} ${WRAPPING_INLINE_CHIP_CLASSES} ${inlineChipIconClasses(presentation.icon)} cursor-text`,
"data-buzz-link": "",
"data-channel-name": presentation.channelName,
"data-composer-buzz-link": "",
@@ -340,7 +380,8 @@ export const ComposerMessageLinkNode =
...presentation.dataAttributes,
title: presentation.ariaLabel,
}),
presentation.label,
content.leading,
content.remainder,
];
},

View File

@@ -256,11 +256,11 @@
Body copy is text-sm; inline code labels sit one step down (text-xs).
All chip variants share one box height so mono labels stay balanced.
Rendered-message chips use inline-flex. The same decoration in the
composer pulls the caret into the chip and swallows the trailing
space after an @mention, so typing "hello" after @quinn becomes
"@quinnhello". Keep composer chips inline; leave ::before icons in
place. */
Plain-text mention decorations stay inline because inline-flex pulls the
caret into their decorated range and swallows the trailing space after an
@mention. Composer Buzz links are atom nodes, but their labels may still
fragment between characters; only the icon and leading label fragment stay
together. */
.rich-text-composer .tiptap .mention-chip {
display: inline;
min-height: 0;

View File

@@ -211,6 +211,11 @@
white-space: nowrap;
}
.message-markdown
.inline-chip-leading-fragment.inline-chip-with-icon:empty::after {
content: "\200b";
}
.message-markdown .inline-chip-leading-fragment.inline-chip-with-icon::before {
display: block;
left: 0;

View File

@@ -1121,7 +1121,7 @@ test("bare Buzz permalinks render cohesive icon-prefixed chips", () => {
assert.equal((html.match(/data-channel-deep-link=""/g) ?? []).length, 1);
assert.match(html, /inline-chip-icon-channel/);
assert.match(html, /wrapping-inline-chip/);
assert.match(html, /inline-chip-leading-fragment[^>]*>e</);
assert.match(html, /inline-chip-leading-fragment[^>]*>engin</);
assert.match(html, /inline-chip-icon-pr/);
assert.match(html, /inline-chip-icon-issue/);
assert.match(html, /inline-chip-icon-repo/);
@@ -1299,7 +1299,7 @@ test("bare Buzz permalinks shorten unavailable channel identifiers", () => {
);
assert.equal(
(html.match(/inline-chip-leading-fragment[^>]*>5<\/span>80ca78b/g) ?? [])
(html.match(/inline-chip-leading-fragment[^>]*>580ca<\/span>78b/g) ?? [])
.length,
2,
);
@@ -1332,7 +1332,7 @@ test("channel references replace the authored hash with the channel icon", () =>
assert.match(html, /inline-chip-icon-channel/);
assert.match(html, /wrapping-inline-chip/);
assert.match(html, /inline-chip-leading-fragment[^>]*>e</);
assert.match(html, /inline-chip-leading-fragment[^>]*>engin</);
assert.match(html.replace(/<[^>]+>/g, ""), /engineering/);
assert.doesNotMatch(html, />#engineering</);
});

View File

@@ -5,6 +5,7 @@ import { cn } from "@/shared/lib/cn";
import { InlineChip } from "@/shared/ui/InlineChip";
import {
inlineChipIconClasses,
inlineChipLeadingEnd,
type InlineChipIconKind,
truncateInlineChipLabel,
WRAPPING_INLINE_CHIP_CLASSES,
@@ -72,12 +73,21 @@ function wrappingChipContent(
): React.ReactNode {
if (typeof children !== "string" || children.length === 0) return children;
const separatorIndex = children.search(/[-\s]/u);
const leadingEnd =
separatorIndex < 0
? Array.from(children)[0]?.length
: separatorIndex + (children[separatorIndex] === "-" ? 1 : 0);
if (!leadingEnd) return children;
const leadingEnd = inlineChipLeadingEnd(children);
if (!leadingEnd) {
return (
<>
<span
aria-hidden="true"
className={cn(
"inline-chip-leading-fragment",
inlineChipIconClasses(icon),
)}
/>
{children}
</>
);
}
return (
<>

View File

@@ -0,0 +1,147 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";
async function withHookHarness(run) {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
const originals = {
window: globalThis.window,
document: globalThis.document,
navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"),
MutationObserver: globalThis.MutationObserver,
requestAnimationFrame: globalThis.requestAnimationFrame,
cancelAnimationFrame: globalThis.cancelAnimationFrame,
act: globalThis.IS_REACT_ACT_ENVIRONMENT,
};
const frames = [];
const requestAnimationFrame = (callback) => {
frames.push(callback);
return frames.length;
};
const cancelAnimationFrame = (id) => {
frames[id - 1] = null;
};
Object.defineProperty(dom.window, "innerWidth", {
configurable: true,
value: 1000,
});
dom.window.requestAnimationFrame = requestAnimationFrame;
dom.window.cancelAnimationFrame = cancelAnimationFrame;
globalThis.window = dom.window;
globalThis.document = dom.window.document;
globalThis.MutationObserver = dom.window.MutationObserver;
globalThis.requestAnimationFrame = requestAnimationFrame;
globalThis.cancelAnimationFrame = cancelAnimationFrame;
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: dom.window.navigator,
});
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
try {
const React = (await import("react")).default;
const { act } = await import("react");
const { createRoot } = await import("react-dom/client");
const { useInlineTooltipPosition } = await import(
"./useInlineTooltipPosition.ts"
);
let controls;
function Probe() {
controls = useInlineTooltipPosition();
return null;
}
const root = createRoot(
dom.window.document.body.appendChild(
dom.window.document.createElement("div"),
),
);
await act(async () => root.render(React.createElement(Probe)));
const flushFrames = async () => {
const pending = frames.splice(0, frames.length).filter(Boolean);
await act(async () => {
for (const callback of pending) callback();
});
};
const flushMutations = async () => {
await act(async () => Promise.resolve());
};
await run({ act, controls, dom, flushFrames, flushMutations });
await act(async () => root.unmount());
} finally {
globalThis.window = originals.window;
globalThis.document = originals.document;
globalThis.MutationObserver = originals.MutationObserver;
globalThis.requestAnimationFrame = originals.requestAnimationFrame;
globalThis.cancelAnimationFrame = originals.cancelAnimationFrame;
if (originals.navigator) {
Object.defineProperty(globalThis, "navigator", originals.navigator);
} else {
delete globalThis.navigator;
}
globalThis.IS_REACT_ACT_ENVIRONMENT = originals.act;
}
}
function translateX(styleValue) {
return (
Number.parseFloat(styleValue.match(/translate(?:3d)?\(([-\d.]+)px/)?.[1]) ||
0
);
}
test("follows Radix wrapper placement without mutating the wrapper", async () => {
await withHookHarness(
async ({ controls, dom, flushFrames, flushMutations }) => {
const wrapper = dom.window.document.createElement("div");
const content = dom.window.document.createElement("div");
wrapper.append(content);
dom.window.document.body.append(wrapper);
wrapper.style.transform = "translate(400px, 20px)";
content.getBoundingClientRect = () => {
const left =
translateX(wrapper.style.transform) +
(Number.parseFloat(wrapper.style.marginLeft) || 0) +
(Number.parseFloat(content.style.translate) || 0);
return {
bottom: 50,
height: 30,
left,
right: left + 100,
top: 20,
width: 100,
x: left,
y: 20,
toJSON() {},
};
};
const target = dom.window.document.createElement("span");
target.getClientRects = () => [
{ bottom: 20, height: 20, left: 360, right: 430, top: 0, width: 70 },
{ bottom: 40, height: 20, left: 450, right: 600, top: 20, width: 150 },
];
controls.contentRef(content);
controls.onPointerMove({
clientX: 395,
clientY: 10,
currentTarget: target,
});
await flushFrames();
assert.equal(wrapper.style.marginLeft, "");
assert.equal(content.getBoundingClientRect().left, 345);
// Radix may place the popper wrapper after the pointer lifecycle. The
// hook must follow that external placement while leaving its style owned
// by Radix; mutating the wrapper is what made this ordering stale.
wrapper.style.transform = "translate(480px, 20px)";
await flushMutations();
await flushFrames();
assert.equal(wrapper.style.marginLeft, "");
assert.equal(content.getBoundingClientRect().left, 345);
assert.equal(content.getBoundingClientRect().right, 445);
},
);
});

View File

@@ -13,6 +13,7 @@ export function useInlineTooltipPosition() {
const contentRef = React.useRef<HTMLDivElement | null>(null);
const fragmentCenterXRef = React.useRef<number | null>(null);
const placementObserverRef = React.useRef<MutationObserver | null>(null);
const placementFrameRef = React.useRef<number | null>(null);
const placedRef = React.useRef(false);
const positionContent = React.useCallback(() => {
@@ -32,14 +33,18 @@ export function useInlineTooltipPosition() {
);
const correction = desiredLeft - rect.left;
if (Math.abs(correction) < 0.5) return;
const currentOffset = Number.parseFloat(wrapper.style.marginLeft) || 0;
wrapper.style.marginLeft = `${currentOffset + correction}px`;
const currentOffset = Number.parseFloat(content.style.translate) || 0;
content.style.translate = `${currentOffset + correction}px`;
}, []);
const setContentRef = React.useCallback(
(content: HTMLDivElement | null) => {
placementObserverRef.current?.disconnect();
placementObserverRef.current = null;
if (placementFrameRef.current !== null) {
cancelAnimationFrame(placementFrameRef.current);
placementFrameRef.current = null;
}
contentRef.current = content;
placedRef.current = false;
const wrapper = content?.parentElement;
@@ -48,27 +53,43 @@ export function useInlineTooltipPosition() {
let transform = wrapper.style.transform;
const handlePlacement = () => {
const nextTransform = wrapper.style.transform;
if (nextTransform === transform) return;
transform = nextTransform;
if (nextTransform !== transform) transform = nextTransform;
placedRef.current = true;
positionContent();
if (placementFrameRef.current !== null) {
cancelAnimationFrame(placementFrameRef.current);
}
placementFrameRef.current = requestAnimationFrame(() => {
placementFrameRef.current = null;
positionContent();
});
};
const observer = new MutationObserver(handlePlacement);
const observer = new MutationObserver(() => {
if (wrapper.style.transform !== transform) handlePlacement();
});
observer.observe(wrapper, {
attributeFilter: ["style"],
attributes: true,
});
placementObserverRef.current = observer;
requestAnimationFrame(() => {
placementFrameRef.current = requestAnimationFrame(() => {
placementFrameRef.current = null;
if (contentRef.current !== content) return;
placedRef.current = true;
positionContent();
handlePlacement();
});
},
[positionContent],
);
React.useEffect(() => () => placementObserverRef.current?.disconnect(), []);
React.useEffect(
() => () => {
placementObserverRef.current?.disconnect();
if (placementFrameRef.current !== null) {
cancelAnimationFrame(placementFrameRef.current);
}
},
[],
);
const onPointerMove = React.useCallback(
(event: React.PointerEvent<HTMLElement>) => {
@@ -77,7 +98,7 @@ export function useInlineTooltipPosition() {
);
if (rects.length <= 1) {
fragmentCenterXRef.current = null;
contentRef.current?.parentElement?.style.removeProperty("margin-left");
contentRef.current?.style.removeProperty("translate");
return;
}
const fragment = rects.reduce<DOMRect | null>((nearest, rect) => {
@@ -90,6 +111,14 @@ export function useInlineTooltipPosition() {
if (!fragment) return;
fragmentCenterXRef.current = fragment.left + fragment.width / 2;
positionContent();
if (placementFrameRef.current !== null) {
cancelAnimationFrame(placementFrameRef.current);
}
placementFrameRef.current = requestAnimationFrame(() => {
placementFrameRef.current = null;
placedRef.current = true;
positionContent();
});
},
[positionContent],
);

View File

@@ -3,14 +3,45 @@ export const MENTION_CHIP_BASE_CLASSES = "mention-chip";
export const MENTION_CHIP_HOVER_CLASSES = "mention-chip-hover";
const INLINE_CHIP_LABEL_MAX_CHARACTERS = 48;
const INLINE_CHIP_LEADING_MAX_GRAPHEMES = 5;
const inlineChipGraphemeSegmenter =
typeof Intl.Segmenter === "function"
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: null;
function inlineChipGraphemes(label: string): string[] {
return inlineChipGraphemeSegmenter
? Array.from(
inlineChipGraphemeSegmenter.segment(label),
({ segment }) => segment,
)
: Array.from(label);
}
/** Caps a fragmentable chip label without changing its underlying metadata. */
export function truncateInlineChipLabel(label: string): string {
const characters = Array.from(label);
if (characters.length <= INLINE_CHIP_LABEL_MAX_CHARACTERS) return label;
return `${characters
.slice(0, INLINE_CHIP_LABEL_MAX_CHARACTERS - 1)
.join("")}…`;
const graphemes = inlineChipGraphemes(label);
if (graphemes.length <= INLINE_CHIP_LABEL_MAX_CHARACTERS) return label;
return `${graphemes.slice(0, INLINE_CHIP_LABEL_MAX_CHARACTERS - 1).join("")}…`;
}
/** Returns a bounded boundary after the icon-bearing prefix. */
export function inlineChipLeadingEnd(label: string): number {
const graphemes = inlineChipGraphemes(label);
if (graphemes.length === 0) return 0;
let offset = 0;
for (const grapheme of graphemes.slice(
0,
INLINE_CHIP_LEADING_MAX_GRAPHEMES,
)) {
const nextOffset = offset + grapheme.length;
if (/\s/u.test(grapheme)) return offset;
offset = nextOffset;
if (grapheme === "-") return offset;
}
return offset;
}
/** Allows a long chip to fragment into separately decorated line boxes. */

View File

@@ -445,6 +445,204 @@ test("mixed Buzz permalinks render as chips in the composer", async ({
await expect(composerInput).not.toContainText("buzz://");
});
test("composer Buzz chip labels wrap without orphaning their icons", async ({
page,
}) => {
await page.addInitScript(() => {
window.__BUZZ_E2E_EXTRA_PROJECT_EVENTS__ = [
{
id: "mock-project-relaytoolsobservabilityconsole-main",
kind: 30617,
pubkey:
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
created_at: Math.floor(Date.now() / 1000) - 60,
content:
"Operator tooling and observability console for relay deployments.",
tags: [
["d", "relaytoolsobservabilityconsole-main"],
["name", "relaytoolsobservabilityconsole-main"],
[
"description",
"Operator tooling and observability console for relay deployments.",
],
["clone", "https://github.com/block/relay-tools.git"],
],
},
];
});
await page.goto("/");
await page.getByTestId("channel-general").click();
const composerInput = page.getByTestId("message-input");
const repoLink =
"buzz://repo?owner=953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f&d=relaytoolsobservabilityconsole-main";
await composerInput.evaluate((element, text) => {
const clipboardData = new DataTransfer();
clipboardData.setData("text/plain", text);
element.dispatchEvent(
new ClipboardEvent("paste", {
bubbles: true,
cancelable: true,
clipboardData,
}),
);
}, repoLink);
const chip = composerInput.locator('[data-composer-buzz-link=""]');
const leadingFragment = chip.locator(".inline-chip-leading-fragment");
await expect(chip).toHaveText("relaytoolsobservabilityconsole-main");
const emptyLeadingFragmentHeight = await composerInput.evaluate((element) => {
const probe = document.createElement("span");
probe.className = "mention-chip wrapping-inline-chip";
const leading = document.createElement("span");
leading.className =
"inline-chip-leading-fragment inline-chip-with-icon inline-chip-icon-repo";
const remainder = document.createElement("span");
remainder.textContent = " leading-space";
probe.append(leading, remainder);
element.append(probe);
const height = leading.getBoundingClientRect().height;
probe.remove();
return height;
});
expect(emptyLeadingFragmentHeight).toBeGreaterThan(0);
const chipHeightAtWidth = async (width: number) => {
await composerInput.evaluate((element, nextWidth) => {
element.style.width = `${nextWidth}px`;
}, width);
return chip.evaluate((element) => {
const range = document.createRange();
range.selectNodeContents(element);
const rects = Array.from(range.getClientRects()).filter(
(rect) => rect.width > 0 && rect.height > 0,
);
return (
Math.max(...rects.map((rect) => rect.bottom)) -
Math.min(...rects.map((rect) => rect.top))
);
});
};
const wideHeight = await chipHeightAtWidth(420);
const mediumHeight = await chipHeightAtWidth(210);
const narrowHeight = await chipHeightAtWidth(90);
expect(mediumHeight).toBeGreaterThan(wideHeight);
expect(narrowHeight).toBeGreaterThan(mediumHeight);
await expect(chip).toHaveCSS("display", "inline");
await expect(chip).toHaveCSS("overflow-wrap", "anywhere");
await expect(leadingFragment).toHaveText("relay");
await expect
.poll(() =>
chip.evaluate((element) => {
const chipRange = document.createRange();
chipRange.selectNodeContents(element);
const leading = element.querySelector(".inline-chip-leading-fragment");
const composerBounds = element
.closest('[data-testid="message-input"]')
?.getBoundingClientRect();
const leadingBounds = leading?.getBoundingClientRect();
return {
chipWraps:
new Set(
Array.from(chipRange.getClientRects(), (rect) =>
Math.round(rect.y),
),
).size >= 2,
leadingContained:
Boolean(composerBounds && leadingBounds) &&
leadingBounds.right <= composerBounds.right,
leadingLineBoxes: leading?.getClientRects().length ?? 0,
};
}),
)
.toMatchObject({
chipWraps: true,
leadingContained: true,
leadingLineBoxes: 1,
});
await expect(chip).toHaveAttribute(
"title",
"Open repository relaytoolsobservabilityconsole-main",
);
await chipHeightAtWidth(420);
await composerInput.press("End");
await composerInput.pressSequentially(" after");
await expect(composerInput).toContainText(
"relaytoolsobservabilityconsole-main after",
);
await expect(chip).toHaveText("relaytoolsobservabilityconsole-main");
await page.getByTestId("send-message").click();
const sentChip = page.getByTestId("message-row").last().getByRole("button", {
name: "Open repository relaytoolsobservabilityconsole-main",
});
await expect(sentChip).toBeVisible();
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) => ({
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);
const tooltip = page.getByRole("tooltip");
await sentChip.focus();
await expect(tooltip).toBeVisible();
const positionOverFragment = async (index: number) => {
const fragment = await sentChip.evaluate((element, fragmentIndex) => {
const rects = Array.from(element.getClientRects()).filter(
(rect) => rect.width > 0 && rect.height > 0,
);
const rect = rects.at(fragmentIndex);
if (!rect) return null;
return {
height: rect.height,
left: rect.left,
top: rect.top,
width: rect.width,
};
}, index);
if (!fragment) throw new Error(`Expected chip fragment ${index}`);
const cursorX = fragment.left + fragment.width / 2;
const cursorY = fragment.top + fragment.height / 2;
await page.mouse.move(cursorX, cursorY);
await expect(tooltip).toBeVisible();
let tooltipCenter = Number.NaN;
await expect
.poll(async () => {
const tooltipBox = await tooltip.boundingBox();
if (!tooltipBox) return Number.POSITIVE_INFINITY;
tooltipCenter = tooltipBox.x + tooltipBox.width / 2;
return Math.abs(tooltipCenter - cursorX);
})
.toBeLessThan(1);
return {
cursorX,
tooltipCenter,
};
};
const firstTooltip = await positionOverFragment(0);
const lastTooltip = await positionOverFragment(-1);
expect(
Math.abs(firstTooltip.tooltipCenter - firstTooltip.cursorX),
).toBeLessThan(1);
expect(
Math.abs(lastTooltip.tooltipCenter - lastTooltip.cursorX),
).toBeLessThan(1);
expect(
Math.abs(firstTooltip.tooltipCenter - lastTooltip.tooltipCenter),
).toBeGreaterThan(1);
});
test("message links to visible root messages open the thread panel", async ({
page,
}) => {
@@ -593,7 +791,7 @@ test("message links to visible root messages open the thread panel", async ({
await expect(randomChannelLink).toHaveCSS("display", "inline");
await expect(
randomChannelLink.locator(".inline-chip-leading-fragment"),
).toHaveText("r");
).toHaveText("rando");
await expect(randomChannelLink).not.toHaveAttribute("title");
await randomChannelLink.hover();
const channelTooltip = page.getByRole("tooltip");