fix(desktop): let inbox title and message author names truncate under narrow panes (#7550)

## Summary

Fixes two instances of the same dead-truncate pattern in the desktop
app, where a flex item's implicit `min-width: auto` prevented `truncate`
from engaging, so long text painted over adjacent controls instead of
ellipsizing:

- **Inbox detail title** (`InboxDetailPane.tsx`): the clickable
context-title button sized to its text instead of shrinking with the
pane, overlapping the header controls (open-in-channel, members, huddle,
more menu). Fixed by adding `max-w-full`.
- **Message author names** (`MessageHeader.tsx` /
`UserProfilePopover.tsx`): the `UserProfilePopover` inline-flex trigger
wrapper refused to shrink below the name's nowrap width, running long
author names under the hover action bar and off the pane edge. Fixed by
adding a `triggerClassName` prop to `UserProfilePopover` and passing
`min-w-0 max-w-full` at the author call site.

Two other suspected instances (project file breadcrumb, drafts pane
title) were stress-tested and already truncate correctly — no change.

### Related issue

N/A — none found.

### Testing

- New Playwright regression tests for both fixes
(`inbox-title-overlap.spec.ts`, `message-author-overlap.spec.ts`,
registered in the smoke project), each proven to discriminate: they fail
with the fix reverted (real measured overlap) and assert the ellipsis
actually engages with non-zero title width, so they can't pass
vacuously.
- Typecheck, lint, and full desktop unit suite green (pre-push hooks);
full desktop e2e smoke suite run earlier: 1402 passed, 3 pre-existing
unrelated failures (each fails identically with the fix reverted).

**Inbox title — before** (long title paints under the header controls):

![Inbox title before: title text overlaps the header control
icons](https://github.com/user-attachments/assets/d5a10f98-114f-4466-8012-468616b82807)

**Inbox title — after** (truncates with ellipsis, controls stay clear):

![Inbox title after: title truncates with an ellipsis before the
controls](https://github.com/user-attachments/assets/80520f7c-1120-4dbc-929d-1ef5eceb874b)

**Author name — before** (long name runs past the header row edge):

![Author name before: name glyphs bleed past the action
bar](https://github.com/user-attachments/assets/be9743ae-ef51-4db0-8c46-a0649e98f0d5)

**Author name — after** (clean cutoff):

![Author name after: name truncates cleanly inside the header
row](https://github.com/user-attachments/assets/24cc0412-ca99-40c4-add6-13380b3ae065)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: cynfria <yescynthia@gmail.com>
Signed-off-by: Tree Trunks <6ba22921d9dc2ad0aa6ecdf63787ddd24726e266d866da31af69f2e4e146ace5@buzz.block.builderlab.xyz>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Tree Trunks <6ba22921d9dc2ad0aa6ecdf63787ddd24726e266d866da31af69f2e4e146ace5@buzz.block.builderlab.xyz>
This commit is contained in:
cynfria
2026-09-10 10:00:15 -07:00
committed by GitHub
parent 051c3a270b
commit cec5c8fd92
10 changed files with 398 additions and 8 deletions

View File

@@ -142,6 +142,8 @@ export default defineConfig({
"**/drafts-screenshots.spec.ts",
"**/drafts-all-fix-screenshots.spec.ts",
"**/inbox-refactor-screenshots.spec.ts",
"**/inbox-title-overlap.spec.ts",
"**/message-author-overlap.spec.ts",
"**/buzz-theme-screenshots.spec.ts",
"**/appearance-previews.spec.ts",
"**/channel-sort.spec.ts",

View File

@@ -567,7 +567,7 @@ function InboxMessageDetailPane({
{canOpenChannel && contextChannelId ? (
<h2 className="min-w-0">
<button
className="block min-w-0 text-left text-sm font-semibold leading-5 tracking-tight text-foreground hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
className="block min-w-0 max-w-full text-left text-sm font-semibold leading-5 tracking-tight text-foreground hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
data-testid="home-inbox-context-title"
onClick={() =>
onOpenContext(

View File

@@ -396,6 +396,7 @@ function isCustomEmojiShortcode(emoji: string) {
export const MessageActionBar = React.memo(function MessageActionBar({
channelId,
message,
ref,
onDelete,
onEdit,
onFollowThread,
@@ -417,6 +418,9 @@ export const MessageActionBar = React.memo(function MessageActionBar({
* action is hidden (callers like the home inbox that lack the context). */
channelId?: string | null;
message: TimelineMessage;
/** Attached to the root element so hosts can measure the rail's rendered
* footprint (e.g. to reserve its width in the message-header layout). */
ref?: React.Ref<HTMLDivElement>;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onFollowThread?: (message: TimelineMessage) => void;
@@ -514,6 +518,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({
: "",
)}
data-testid={`message-action-bar-${message.id}`}
ref={ref}
>
<div className="overflow-hidden rounded-full border border-border/70 bg-background/95 shadow-xs backdrop-blur-sm supports-[backdrop-filter]:bg-background/85">
<div className="flex items-center gap-0.5 p-1">

View File

@@ -129,6 +129,7 @@ export function MessageAuthorIdentity({
pubkey={pubkey}
role={role}
botIdenticonValue={displayName}
triggerClassName="min-w-0 max-w-full"
>
<button
className="truncate rounded leading-message-author focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"

View File

@@ -34,6 +34,7 @@ import {
} from "@/shared/constants/kinds";
import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAuthPubkey";
import { cn } from "@/shared/lib/cn";
import { useMeasuredCssVariable } from "@/shared/layout/useMeasuredCssVariable";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
@@ -214,6 +215,19 @@ export const MessageRow = React.memo(
} = useReactionHandler(message, onToggleReaction);
const { openReminder, activeReminderEventIds } = useRemindLater();
const hasActiveReminder = activeReminderEventIds.has(message.id);
// The hover/focus action rail is absolutely positioned over the row, so it
// takes no layout space of its own. Measure its rendered footprint and
// reserve it in the message-header row (see `headerNode`) so a long author
// name ellipsizes before the rail instead of painting underneath it. The
// reservation is unconditional — the rail appears on hover AND
// focus-within, and padding must not reflow the header mid-interaction.
const articleRef = React.useRef<HTMLElement | null>(null);
const actionRailMeasureRef = useMeasuredCssVariable({
cssVariable: "--message-action-rail-width",
dimension: "inline",
resetValue: "0px",
targetRef: articleRef,
});
const handleRemindLater = React.useCallback(
(msg: TimelineMessage) => {
openReminder({
@@ -565,6 +579,7 @@ export const MessageRow = React.memo(
>
<MessageActionBar
channelId={channelId}
ref={actionRailMeasureRef}
isFollowingThread={isFollowingThread}
isUnread={isUnread}
message={message}
@@ -639,7 +654,9 @@ export const MessageRow = React.memo(
) : null;
const headerNode = isDisplayedAsContinuation ? null : (
<MessageHeaderRow>
// pe reserves the measured action-rail footprint (0px until measured) so
// header content ends before the rail's left edge in every rail state.
<MessageHeaderRow className="pe-[var(--message-action-rail-width,0px)]">
{message.pubkey ? (
<MessageAuthorWithIndicators
authorName={message.author}
@@ -892,6 +909,7 @@ export const MessageRow = React.memo(
data-message-id={message.id}
data-testid="message-row"
onAnimationEnd={handleEntranceAnimationEnd}
ref={articleRef}
>
{isThreadReplyLayout ? (
<>

View File

@@ -49,6 +49,13 @@ type UserProfilePopoverProps = {
children: React.ReactNode;
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.
*/
triggerClassName?: string;
/** Accessible name for interactive trigger content that is visually hidden. */
triggerAriaLabel?: string;
/** Set false when the trigger is inside another interactive control. */
@@ -129,6 +136,7 @@ export function UserProfilePopover({
children,
pubkey,
triggerElement = "div",
triggerClassName,
triggerAriaLabel,
enableProfilePanel = true,
enableHoverPopover = true,
@@ -213,6 +221,7 @@ export function UserProfilePopover({
onMouseLeave={handleMouseLeave}
className={cn(
"inline-flex",
triggerClassName,
canOpenProfilePanel && "cursor-pointer [&_*]:cursor-pointer",
)}
>

View File

@@ -29,3 +29,35 @@ export function observeElementBlockSize(
observer.disconnect();
};
}
/**
* Reads the inline size from a ResizeObserver entry, preferring border-box
* size when available.
*/
export function readElementInlineSize(entry: ResizeObserverEntry): number {
return entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
}
/**
* Invokes `onSize` immediately and whenever the observed element's inline size
* changes. Returns a cleanup function that disconnects the observer.
*/
export function observeElementInlineSize(
element: HTMLElement,
onSize: (size: number) => void,
): () => void {
onSize(element.getBoundingClientRect().width);
if (typeof ResizeObserver === "undefined") {
return () => {};
}
const observer = new ResizeObserver(([entry]) => {
onSize(readElementInlineSize(entry));
});
observer.observe(element);
return () => {
observer.disconnect();
};
}

View File

@@ -1,9 +1,14 @@
import * as React from "react";
import { observeElementBlockSize } from "./observeElementBlockSize";
import {
observeElementBlockSize,
observeElementInlineSize,
} from "./observeElementBlockSize";
type UseMeasuredCssVariableArgs = {
cssVariable: string;
/** Which box dimension to observe. Defaults to `"block"` (height). */
dimension?: "block" | "inline";
enabled?: boolean;
resetKey?: unknown;
resetValue: string;
@@ -11,9 +16,9 @@ type UseMeasuredCssVariableArgs = {
};
/**
* Observes an element's block size and writes it as a CSS custom property on a
* target element. Uses `useLayoutEffect` so the first measurement happens
* before paint.
* Observes an element's block (or inline) size and writes it as a CSS custom
* property on a target element. Uses `useLayoutEffect` so the first
* measurement happens before paint.
*
* Returns a callback ref for the source element. Attach it instead of a ref
* object so the measurement re-runs when the source mounts later than this
@@ -22,6 +27,7 @@ type UseMeasuredCssVariableArgs = {
export function useMeasuredCssVariable({
targetRef,
cssVariable,
dimension = "block",
resetValue,
resetKey,
enabled = true,
@@ -53,13 +59,25 @@ export function useMeasuredCssVariable({
targetEl.style.setProperty(cssVariable, `${px}px`);
};
const disconnect = observeElementBlockSize(sourceEl, applySize);
const observe =
dimension === "inline"
? observeElementInlineSize
: observeElementBlockSize;
const disconnect = observe(sourceEl, applySize);
return () => {
disconnect();
targetEl.style.setProperty(cssVariable, resetValue);
};
}, [sourceEl, targetRef, cssVariable, resetValue, resetKey, enabled]);
}, [
sourceEl,
targetRef,
cssVariable,
dimension,
resetValue,
resetKey,
enabled,
]);
return setSourceEl;
}

View File

@@ -0,0 +1,146 @@
/**
* Regression test: a long inbox detail title must truncate instead of
* running underneath the header controls (open-in-channel, members,
* huddle, more menu) when the detail pane is narrow.
*
* Run: pnpm build:e2e && pnpm exec playwright test --project=smoke \
* tests/e2e/inbox-title-overlap.spec.ts
* Output: test-results/inbox-title-overlap/
*/
import { expect, test } from "@playwright/test";
import type { RelayEvent } from "../../src/shared/api/types";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const SHOTS = "test-results/inbox-title-overlap";
const ENGINEERING_CHANNEL_ID = "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9";
type MockWindow = Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
parentEventId?: string | null;
pubkey?: string;
mentionPubkeys?: string[];
id?: string;
}) => RelayEvent;
__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: {
category: "mention" | "needs_action" | "activity" | "agent_activity";
channel_id: string | null;
channel_name: string;
content: string;
created_at: number;
id: string;
kind: number;
pubkey: string;
tags: string[][];
}) => unknown;
};
test.describe("inbox detail title overflow", () => {
// Narrow window matching the report; the list pane is widened to its
// resizable maximum so the detail pane is squeezed to its 300px minimum
// and "Message in #engineering" cannot fit next to the header controls.
test.use({ viewport: { width: 1046, height: 838 } });
test("long title truncates instead of overlapping header controls", async ({
page,
}) => {
await page.addInitScript(() => {
window.sessionStorage.setItem(
"buzz.desktop.home-inbox-list-width",
"520",
);
});
await installMockBridge(page, { mode: "mock" });
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("home-inbox-list")).toBeVisible({
timeout: 10_000,
});
await page.waitForFunction(() => {
const win = window as MockWindow;
return (
typeof win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
typeof win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function"
);
});
const mentionId = "ab".repeat(32);
await page.evaluate(
({ channelId, currentPubkey, id, senderPubkey }) => {
const win = window as MockWindow;
const emit = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
const push = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!emit || !push) {
throw new Error("Mock bridge helpers are not installed.");
}
const event = emit({
channelName: "engineering",
content:
"Hey, can you look at the release checklist before tomorrow?",
id,
mentionPubkeys: [currentPubkey],
pubkey: senderPubkey,
});
push({
category: "mention",
channel_id: channelId,
channel_name: "engineering",
content: event.content,
created_at: event.created_at,
id: event.id,
kind: event.kind,
pubkey: event.pubkey,
tags: event.tags,
});
},
{
channelId: ENGINEERING_CHANNEL_ID,
currentPubkey: TEST_IDENTITIES.tyler.pubkey,
id: mentionId,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
},
);
const row = page.getByTestId(`home-inbox-item-${mentionId}`);
await expect(row).toBeVisible();
await row.click();
const detail = page.getByTestId("home-inbox-detail");
await expect(detail).toBeVisible();
const title = detail.getByTestId("home-inbox-context-title");
await expect(title).toHaveText("Message in #engineering");
await waitForAnimations(page);
const titleBox = await title.boundingBox();
const controlsBox = await detail
.getByTestId("home-inbox-open-context")
.boundingBox();
if (!titleBox || !controlsBox) {
throw new Error("Header title or controls not rendered.");
}
// Guard against a vacuous pass: the title must still render with real
// width, and the squeeze must actually engage — the inner span's
// ellipsis is active only when its content overflows. If layout
// constants or fonts ever change so the full title fits, this fails
// loudly instead of silently no longer testing truncation.
expect(titleBox.width).toBeGreaterThan(0);
const isTruncated = await title.evaluate((element) => {
const span = element.querySelector("span");
return span ? span.scrollWidth > span.clientWidth : false;
});
expect(isTruncated).toBe(true);
await page.screenshot({
clip: { height: 96, width: 1046, x: 0, y: 0 },
path: `${SHOTS}/01-header.png`,
});
// The title's right edge must stop short of the first header control.
expect(titleBox.x + titleBox.width).toBeLessThanOrEqual(controlsBox.x + 1);
});
});

View File

@@ -0,0 +1,159 @@
/**
* Regression test: a long message-author display name must truncate instead
* of running past the message header into the timeline edge and the hover
* action bar. The author button sits inside the UserProfilePopover trigger
* wrapper — a flex item whose `min-width: auto` refused to shrink below the
* nowrap width of the name, so the button's `truncate` never engaged.
*
* Run: pnpm build:e2e && pnpm exec playwright test --project=smoke \
* tests/e2e/message-author-overlap.spec.ts
* Output: test-results/message-author-overlap/
*/
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const SHOTS = "test-results/message-author-overlap";
// Long single-word name: no break opportunities, so the nowrap min-content
// width far exceeds the header row at a 900px viewport.
const LONG_AUTHOR_NAME =
"Alexandrina-Wolfeschlegelsteinhausenbergerdorff-Keihanaikukauakahihuliheekahaunaele-The-Magnificent-Third";
const MESSAGE_CONTENT = "Author-name truncation regression probe";
test.describe("message author name overflow", () => {
test.use({ viewport: { width: 900, height: 700 } });
test("long author name truncates instead of overflowing the header row", async ({
page,
}) => {
await installMockBridge(page, {
mode: "mock",
searchProfiles: [
{
pubkey: TEST_IDENTITIES.alice.pubkey,
displayName: LONG_AUTHOR_NAME,
},
],
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
await page.evaluate(
({ pubkey, content }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content,
pubkey,
});
},
{ content: MESSAGE_CONTENT, pubkey: TEST_IDENTITIES.alice.pubkey },
);
const row = page
.getByTestId("message-row")
.filter({ hasText: MESSAGE_CONTENT });
await expect(row).toBeVisible();
const author = row.getByTestId("message-author");
await expect(author).toHaveText(LONG_AUTHOR_NAME);
await waitForAnimations(page);
// The ellipsis carrier is the popover-trigger <button> around the author
// span — `truncate` on the inline span itself cannot clip.
const button = author.locator("xpath=ancestor::button[1]");
await expect(button).toBeVisible();
const buttonBox = await button.boundingBox();
const headerBox = await row.getByTestId("message-header").boundingBox();
if (!buttonBox || !headerBox) {
throw new Error("Author button or message header not rendered.");
}
// Guard against a vacuous pass: the author must still render with real
// width, and the squeeze must actually engage — the button's ellipsis is
// active only when its content overflows. If layout constants or fonts
// ever change so the full name fits, this fails loudly instead of
// silently no longer testing truncation.
expect(buttonBox.width).toBeGreaterThan(0);
const isTruncated = await button.evaluate(
(element) => element.scrollWidth > element.clientWidth,
);
expect(isTruncated).toBe(true);
await page.screenshot({
clip: {
height: 72,
width: 900,
x: 0,
y: Math.max(0, headerBox.y - 12),
},
path: `${SHOTS}/01-author-header.png`,
});
// The name's right edge must stay inside the header row instead of
// running under the pane edge / hover action bar.
expect(buttonBox.x + buttonBox.width).toBeLessThanOrEqual(
headerBox.x + headerBox.width + 1,
);
// The action rail (reactions/reply controls) is absolutely positioned OVER
// the row and appears on hover and on focus-within. In both states the
// author's painted box must end before the rail's left edge — a name that
// merely stays inside the header can still be painted over by the rail.
const actionBar = row.locator('[data-testid^="message-action-bar-"]');
const expectAuthorClearOfActionBar = async (state: string) => {
// Playwright's toBeVisible() passes at opacity 0; the rail's shown/hidden
// states are driven by opacity, so assert the computed style directly.
await expect(actionBar).toHaveCSS("opacity", "1");
const authorBox = await button.boundingBox();
const actionBarBox = await actionBar.boundingBox();
if (!authorBox || !actionBarBox) {
throw new Error(`Author or action bar not rendered on ${state}.`);
}
expect(actionBarBox.width).toBeGreaterThan(0);
// The squeeze must still be a real ellipsis, not a vacuous fit.
const stillTruncated = await button.evaluate(
(element) => element.scrollWidth > element.clientWidth,
);
expect(stillTruncated).toBe(true);
expect(authorBox.x + authorBox.width).toBeLessThanOrEqual(actionBarBox.x);
};
// Pointer hover.
await row.hover();
await waitForAnimations(page);
await page.screenshot({
clip: {
height: 72,
width: 900,
x: 0,
y: Math.max(0, headerBox.y - 12),
},
path: `${SHOTS}/02-author-header-hovered.png`,
});
await expectAuthorClearOfActionBar("hover");
// Reset the pointer, then re-reveal the rail via keyboard focus-within.
await page.mouse.move(0, 0);
await expect(actionBar).toHaveCSS("opacity", "0");
await button.focus();
await waitForAnimations(page);
await page.screenshot({
clip: {
height: 72,
width: 900,
x: 0,
y: Math.max(0, headerBox.y - 12),
},
path: `${SHOTS}/03-author-header-focused.png`,
});
await expectAuthorClearOfActionBar("focus-within");
});
});