From b6dc533b7c2aadb70a8f011a88a5bd2f06d800f2 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 2 Sep 2026 14:08:12 -0700 Subject: [PATCH] fix(composer): align wrapped inline chip fragments (#7242) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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.
File changes **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.
## 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 Co-authored-by: Carl --- .../src/shared/styles/globals/composer.css | 30 ++++++- desktop/tests/e2e/mentions.spec.ts | 78 +++++++++++++++++++ 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/desktop/src/shared/styles/globals/composer.css b/desktop/src/shared/styles/globals/composer.css index 5ebe4b5775..d54c1cbdb7 100644 --- a/desktop/src/shared/styles/globals/composer.css +++ b/desktop/src/shared/styles/globals/composer.css @@ -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); +} diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 4db24a9f5a..63967e285d 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -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, }) => {