From f79d346a178408661fcad85122364ac2ad7e9cb2 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 24 Aug 2026 14:55:05 -0700 Subject: [PATCH] fix(composer): wrap Buzz chip labels without orphaning icons (#6581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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.
File changes **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.
## 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 Co-authored-by: Carl --- .../lib/composerMessageLinkNode.test.mjs | 57 ++++- .../messages/lib/composerMessageLinkNode.ts | 45 +++- .../src/shared/styles/globals/composer.css | 10 +- .../src/shared/styles/globals/markdown.css | 5 + desktop/src/shared/ui/markdown.test.mjs | 6 +- .../src/shared/ui/markdown/BuzzLinkChip.tsx | 22 +- .../useInlineTooltipPosition.test.mjs | 147 +++++++++++++ .../ui/markdown/useInlineTooltipPosition.ts | 49 ++++- desktop/src/shared/ui/mentionChip.ts | 41 +++- desktop/tests/e2e/navigation.spec.ts | 200 +++++++++++++++++- 10 files changed, 545 insertions(+), 37 deletions(-) create mode 100644 desktop/src/shared/ui/markdown/useInlineTooltipPosition.test.mjs diff --git a/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs b/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs index 3695275762..57212aeafe 100644 --- a/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs +++ b/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs @@ -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", () => { diff --git a/desktop/src/features/messages/lib/composerMessageLinkNode.ts b/desktop/src/features/messages/lib/composerMessageLinkNode.ts index 9587c312ca..59b15c78ba 100644 --- a/desktop/src/features/messages/lib/composerMessageLinkNode.ts +++ b/desktop/src/features/messages/lib/composerMessageLinkNode.ts @@ -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]; 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({ 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, ]; }, diff --git a/desktop/src/shared/styles/globals/composer.css b/desktop/src/shared/styles/globals/composer.css index fbbcdf026c..781ac8a196 100644 --- a/desktop/src/shared/styles/globals/composer.css +++ b/desktop/src/shared/styles/globals/composer.css @@ -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; diff --git a/desktop/src/shared/styles/globals/markdown.css b/desktop/src/shared/styles/globals/markdown.css index e443357ae8..9d0c382ac3 100644 --- a/desktop/src/shared/styles/globals/markdown.css +++ b/desktop/src/shared/styles/globals/markdown.css @@ -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; diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index 8136ff9f08..6cd15c69b4 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -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]*>engin { ); 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]*>engin]+>/g, ""), /engineering/); assert.doesNotMatch(html, />#engineering +