fix(composer): align wrapped inline chip fragments (#7242)

**Category:** fix
**User Impact:** Wrapped channel and mention chips in the chat composer
now align continuation text with the chip edge while keeping the icon on
the first line.

**Problem:** Plain composer decorations used absolute icons plus cloned
icon-sized padding, so every wrapped fragment inherited an empty icon
gap and the icon aligned against the union of all lines. **Solution:**
Keep the icon in the first fragment's inline flow and restore normal
chip padding on continuation fragments, while explicitly leaving the
separate wrapping Buzz-link and sent-message rendering paths unchanged.

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

**desktop/src/shared/styles/globals/composer.css**
Scopes in-flow icon geometry and normal continuation padding to plain
composer mention and channel decorations, excluding wrapping atom-link
chips and preserving the human-icon vertical correction.

**desktop/tests/e2e/mentions.spec.ts**
Adds a rendered narrow-composer regression that checks two fragments,
static icon geometry, first-line icon space, and continuation-line
alignment to ordinary chip padding.

</details>

## Reproduction steps

1. Open a channel in Buzz Desktop.
2. Narrow the chat composer enough for `#all-replies` to wrap.
3. Confirm the channel icon occupies only the first line and `replies`
starts at the chip's normal left padding rather than an icon-sized
inset.
4. Send or view a long inline Buzz chip in the message list at a
constrained width.
5. Confirm its icon remains attached to the leading fragment and its
remaining label continues cleanly on following lines.

## Screenshots

**Composer — wrapped `#all-replies` channel reference**

![Wrapped channel reference in the narrow dark-theme
composer](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/7242/composer-wrapped-channel.png)

**Message list — existing wrapped inline-chip rendering preserved**

![Wrapped repository chip in a sent message at constrained
width](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/7242/message-list-wrapped-chip.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-09-02 14:08:12 -07:00
committed by GitHub
parent 91ab9d31b8
commit b6dc533b7c
2 changed files with 104 additions and 4 deletions

View File

@@ -259,10 +259,10 @@
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. The line-height keeps each inline decoration box tall enough to
paint both block-padding edges without clipping. */
@mention. Their generated icon participates in the first line's inline
flow: absolute positioning anchors it to the union of every wrapped line,
and icon-sized left padding is cloned onto every continuation fragment.
Composer Buzz links are atom nodes with their own leading-fragment wrapper. */
.rich-text-composer .tiptap .mention-chip {
display: inline;
min-height: 0;
@@ -272,3 +272,25 @@
var(--inline-chip-padding-block-end)
);
}
.rich-text-composer
.tiptap
.mention-chip.inline-chip-with-icon:not(.wrapping-inline-chip) {
padding-left: var(--inline-chip-padding-inline);
}
.rich-text-composer
.tiptap
.mention-chip.inline-chip-with-icon:not(.wrapping-inline-chip)::before {
position: static;
display: inline-block;
margin-right: var(--inline-chip-icon-gap);
transform: none;
vertical-align: -0.125em;
}
.rich-text-composer
.tiptap
.mention-chip.inline-chip-icon-human:not(.wrapping-inline-chip)::before {
transform: translateY(0.0625rem);
}

View File

@@ -1190,6 +1190,84 @@ test("typing an unregistered @token before existing text is left alone", async (
await expect(input).toHaveText("hello @zzq world");
});
test("wrapped channel references keep the icon on the first composer line", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await page.getByTestId("message-input-scroll").evaluate((element) => {
element.style.width = "74px";
});
await input.fill("#all-replies");
const channelChip = input.locator(".inline-chip-icon-channel", {
hasText: "all-replies",
});
await expect(channelChip).toBeVisible();
// CSSOM exposes pseudo-element styles but not their rendered box.
const cdp = await page.context().newCDPSession(page);
const { root } = await cdp.send("DOM.getDocument", {
depth: -1,
pierce: true,
});
const { nodeId } = await cdp.send("DOM.querySelector", {
nodeId: root.nodeId,
selector: ".rich-text-composer .inline-chip-icon-channel",
});
const { node } = await cdp.send("DOM.describeNode", { nodeId, depth: 1 });
const before = node.pseudoElements?.find(
(pseudo) => pseudo.pseudoType === "before",
);
if (!before) {
throw new Error("Channel chip is missing its generated icon");
}
const iconBox = await cdp.send("DOM.getBoxModel", { nodeId: before.nodeId });
const iconTop = iconBox.model.border[1];
await cdp.detach();
const geometry = await channelChip.evaluate((element) => {
const textNode = element.firstChild;
if (!(textNode instanceof Text)) {
throw new Error("Channel chip is missing its decorated text node");
}
const textRange = document.createRange();
textRange.selectNodeContents(textNode);
const rects = (source: DOMRectList) =>
Array.from(source, (rect) => ({
left: rect.left,
top: rect.top,
}));
const iconStyle = getComputedStyle(element, "::before");
const tokenProbe = document.createElement("span");
tokenProbe.style.cssText =
"position:fixed;width:var(--inline-chip-padding-inline)";
element.append(tokenProbe);
const tokenPadding = tokenProbe.getBoundingClientRect().width;
tokenProbe.remove();
return {
chipRects: rects(element.getClientRects()),
iconPosition: iconStyle.position,
iconTransform: iconStyle.transform,
tokenPadding,
textRects: rects(textRange.getClientRects()),
};
});
expect(geometry.chipRects).toHaveLength(2);
expect(geometry.textRects).toHaveLength(2);
expect(geometry.iconPosition).toBe("static");
expect(geometry.iconTransform).toBe("none");
expect(
geometry.textRects[0].left - geometry.chipRects[0].left,
).toBeGreaterThan(geometry.tokenPadding);
expect(geometry.textRects[1].left - geometry.chipRects[1].left).toBeCloseTo(
geometry.tokenPadding,
0,
);
expect(iconTop - geometry.textRects[0].top).toBeCloseTo(2.5, 0);
});
test("channel references keep caret movement through the channel name", async ({
page,
}) => {