From d3e84c17ce46a29c96c4905bcb495522db5eb878 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 21 Aug 2026 13:32:03 -0700 Subject: [PATCH 1/3] Keep each timeline message's action row inside its message The hover-revealed action row under a timeline message sat in normal flow with no width bound, so it rendered at its natural width regardless of the message above it. A two-letter bubble (51px) carrying three actions (76px) overhung by 25px on desktop; on a touch phone the latest message's inline row overhung a 54px bubble by 46px. Bound the row to the message it belongs to and collapse what does not fit: - Measure the row's slot with a ResizeObserver and keep only the actions that fit; the rest move into a trailing "..." menu. The row is absolutely positioned inside a full-width slot so a wide row can never widen a fit-content message column. - Wrap the user bubble and its row in a sub-column sized by the bubble, so the measured slot is exactly the bubble's width. - On touch, tapping "..." expands the hidden actions in place when the whole set fits the timeline column with room to spare, reaching into the empty gutter beside a narrow bubble. When the column is too tight the anchored popover is used instead, since it scrolls and cannot clip. - Size both menus to their widest label instead of a fixed width, and keep the row revealed while its own menu is open. --- .../timeline/ConversationMessageContent.tsx | 83 ++- .../thread/timeline/MessageActionBar.test.tsx | 265 ++++++++ .../thread/timeline/MessageActionBar.tsx | 633 ++++++++++++------ .../rows/AssistantMessage.stories.tsx | 94 +++ .../timeline/rows/UserMessage.stories.tsx | 131 ++++ 5 files changed, 961 insertions(+), 245 deletions(-) diff --git a/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx b/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx index d61e66cc5b..4b372e139e 100644 --- a/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx +++ b/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx @@ -495,7 +495,9 @@ function UserConversationMessage({ const requestLabel = turnRequestLabel(turnRequest); return ( -
+ // `data-message-column` marks the full timeline width for the action row, + // which expands into this column's empty gutter on touch. +
{requestLabel ? (
@@ -505,42 +507,50 @@ function UserConversationMessage({ />
) : null} -
- {messageText ? ( - +
+ {messageText ? ( + + ) : ( +

Sent attachments

+ )} + - ) : ( -

Sent attachments

- )} - + {/* + The bar's slot sits in normal flow and reserves the row's height + whether or not the hover-revealed actions are showing; it renders + nothing at all when the message has no action. `MessageActionBar` + is the one place that decides which of those two cases holds. + */} +
- {/* - The bar sits in normal flow: it is hidden by opacity, so it occupies - its own height whether or not it is revealed, and it renders nothing - at all when the message has no action. `MessageActionBar` is the one - place that decides which of those two cases holds. - */} -
); @@ -667,7 +677,10 @@ function AssistantConversationMessage({ ]); return ( -
+
{/* Reports in-bounds text selections up to the timeline-level controller that drives the single floating selection menu (Add to chat / Reply in diff --git a/apps/app/src/components/thread/timeline/MessageActionBar.test.tsx b/apps/app/src/components/thread/timeline/MessageActionBar.test.tsx index dc051d7701..8f9ce351d3 100644 --- a/apps/app/src/components/thread/timeline/MessageActionBar.test.tsx +++ b/apps/app/src/components/thread/timeline/MessageActionBar.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { + act, cleanup, fireEvent, render, @@ -12,6 +13,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { COMPACT_VIEWPORT_QUERY } from "@bb/shared-ui/hooks/use-compact-viewport"; import { POINTER_COARSE_QUERY } from "@bb/shared-ui/hooks/use-pointer-coarse"; import { + computeMessageActionRowLayout, findMessageActionTooltipCollisionBoundary, MessageActionBar, } from "./MessageActionBar"; @@ -19,8 +21,55 @@ import { afterEach(() => { cleanup(); vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); +/** + * Replaces the setup polyfill's inert ResizeObserver with one whose + * observations the test can drive. Entries carry only `contentRect`, matching + * the fallback path the component reads when box sizes are absent. + */ +function installControlledResizeObserver() { + const observations: { callback: ResizeObserverCallback; node: Element }[] = + []; + class ControlledResizeObserver { + readonly #callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { + this.#callback = callback; + } + observe(node: Element) { + observations.push({ callback: this.#callback, node }); + } + unobserve() {} + disconnect() {} + } + vi.stubGlobal("ResizeObserver", ControlledResizeObserver); + const report = (widths: { slot: number; column: number }) => { + act(() => { + for (const { callback, node } of observations) { + const width = node.hasAttribute("data-message-column") + ? widths.column + : widths.slot; + callback( + [ + { + target: node, + contentRect: { width, height: 20 }, + } as unknown as ResizeObserverEntry, + ], + undefined as unknown as ResizeObserver, + ); + } + }); + }; + return { + reportWidth(width: number) { + report({ slot: width, column: width }); + }, + reportWidths: report, + }; +} + function mockMobileCoarsePointer() { vi.spyOn(window, "matchMedia").mockImplementation((query) => ({ matches: query === COMPACT_VIEWPORT_QUERY || query === POINTER_COARSE_QUERY, @@ -388,6 +437,157 @@ describe("MessageActionBar", () => { ).toBeNull(); }); + it("collapses desktop actions that do not fit into a trailing overflow menu", () => { + const resizeObserver = installControlledResizeObserver(); + const onAddToChat = vi.fn(); + render( + , + ); + // Three 20px actions with 8px gaps need 76px; a 44px slot fits one action + // plus the 20px "⋯" trigger at its tighter 4px gap (20 + 4 + 20). + resizeObserver.reportWidth(44); + + expect(screen.getByRole("button", { name: "Copy message" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Add to chat" })).toBeNull(); + expect( + screen.queryByRole("button", { name: "Fork into new thread" }), + ).toBeNull(); + + fireEvent.pointerDown(screen.getByRole("button", { name: "More actions" })); + expect( + screen.getByRole("menuitem", { name: "Fork into new thread" }), + ).toBeTruthy(); + fireEvent.click(screen.getByRole("menuitem", { name: "Add to chat" })); + expect(onAddToChat).toHaveBeenCalledWith("An answer."); + }); + + it("keeps every desktop action in the overflow menu when nothing fits inline", () => { + const resizeObserver = installControlledResizeObserver(); + render( + , + ); + resizeObserver.reportWidth(24); + + expect(screen.queryByRole("button", { name: "Copy message" })).toBeNull(); + fireEvent.pointerDown(screen.getByRole("button", { name: "More actions" })); + expect( + screen.getAllByRole("menuitem").map((item) => item.textContent), + ).toEqual(["Copy message", "Add to chat", "Fork into new thread"]); + }); + + it("collapses touch inline actions that do not fit into the mobile popover", () => { + mockMobileCoarsePointer(); + const resizeObserver = installControlledResizeObserver(); + const onFork = vi.fn(); + render( + , + ); + // Three 28px touch actions with 8px gaps need 100px; a 60px slot fits one + // action plus the 28px popover trigger at its 4px gap (28 + 4 + 28). + resizeObserver.reportWidth(60); + + expect(screen.getByRole("button", { name: "Copy message" })).toBeTruthy(); + expect( + screen.queryByRole("button", { name: "Fork into new thread" }), + ).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Message actions" })); + const content = + document.body.querySelector('[data-side="top"]'); + if (!content) throw new Error("Missing mobile message action menu"); + expect( + within(content) + .getAllByRole("button") + .map((button) => button.textContent), + ).toEqual(["Add to chat", "Fork into new thread"]); + fireEvent.click( + within(content).getByRole("button", { name: "Fork into new thread" }), + ); + expect(onFork).toHaveBeenCalledTimes(1); + }); + + it("expands the hidden touch actions inline when the column has room", () => { + mockMobileCoarsePointer(); + const resizeObserver = installControlledResizeObserver(); + const onAddToChat = vi.fn(); + render( +
+ +
, + ); + // Bubble fits nothing; the 358px column fits all three 28px actions. + resizeObserver.reportWidths({ slot: 54, column: 358 }); + + fireEvent.click(screen.getByRole("button", { name: "Message actions" })); + + expect( + screen + .getAllByRole("button") + .map((button) => button.getAttribute("aria-label")), + ).toEqual(["Copy message", "Add to chat", "Fork into new thread"]); + expect(document.body.querySelector('[data-side="top"]')).toBeNull(); + + // Choosing an action runs it and collapses the row again. + fireEvent.click(screen.getByRole("button", { name: "Add to chat" })); + expect(onAddToChat).toHaveBeenCalledWith("An answer."); + expect( + screen.getByRole("button", { name: "Message actions" }), + ).toBeTruthy(); + }); + + it("keeps the popover when the column cannot fit the actions comfortably", () => { + mockMobileCoarsePointer(); + const resizeObserver = installControlledResizeObserver(); + render( +
+ +
, + ); + // Three actions need 100px; 110px leaves less than the comfort margin. + resizeObserver.reportWidths({ slot: 54, column: 110 }); + + fireEvent.click(screen.getByRole("button", { name: "Message actions" })); + + const content = + document.body.querySelector('[data-side="top"]'); + if (!content) throw new Error("Missing mobile message action menu"); + expect( + within(content) + .getAllByRole("button") + .map((button) => button.textContent), + ).toEqual(["Copy message", "Add to chat", "Fork into new thread"]); + }); + it("mounts the tooltip bar on fine-pointer viewports", () => { render( { expect(fork.getAttribute("data-state")).toBe("closed"); }); }); + +describe("computeMessageActionRowLayout", () => { + const metrics = { actionWidth: 20, overflowTriggerWidth: 20 }; + + it("renders everything inline before the slot is measured", () => { + expect( + computeMessageActionRowLayout({ + actionCount: 5, + availableWidth: undefined, + ...metrics, + }), + ).toEqual({ inlineCount: 5, overflowCount: 0 }); + }); + + it("keeps all actions inline when they exactly fit", () => { + // 3 × 20px + 2 × 8px gaps = 76px. + expect( + computeMessageActionRowLayout({ + actionCount: 3, + availableWidth: 76, + ...metrics, + }), + ).toEqual({ inlineCount: 3, overflowCount: 0 }); + }); + + it("collapses the tail once the full row would overflow", () => { + // One pixel short of fitting all three (76px): two actions plus the + // trigger at its 4px gap need 20 + 8 + 20 + 4 + 20 = 72px and fit. + expect( + computeMessageActionRowLayout({ + actionCount: 3, + availableWidth: 75, + ...metrics, + }), + ).toEqual({ inlineCount: 2, overflowCount: 1 }); + // Below 72px the second action also collapses. + expect( + computeMessageActionRowLayout({ + actionCount: 3, + availableWidth: 71, + ...metrics, + }), + ).toEqual({ inlineCount: 1, overflowCount: 2 }); + }); + + it("puts every action in the menu when not even one fits beside the trigger", () => { + expect( + computeMessageActionRowLayout({ + actionCount: 3, + availableWidth: 30, + ...metrics, + }), + ).toEqual({ inlineCount: 0, overflowCount: 3 }); + }); + + it("returns an empty layout for zero actions", () => { + expect( + computeMessageActionRowLayout({ + actionCount: 0, + availableWidth: 400, + ...metrics, + }), + ).toEqual({ inlineCount: 0, overflowCount: 0 }); + }); +}); diff --git a/apps/app/src/components/thread/timeline/MessageActionBar.tsx b/apps/app/src/components/thread/timeline/MessageActionBar.tsx index 53fe4a04fa..5f7c3c22bb 100644 --- a/apps/app/src/components/thread/timeline/MessageActionBar.tsx +++ b/apps/app/src/components/thread/timeline/MessageActionBar.tsx @@ -1,4 +1,10 @@ -import { useCallback, useEffect, useState } from "react"; +import { + useCallback, + useEffect, + useRef, + useState, + type MouseEvent, +} from "react"; import { flushSync } from "react-dom"; import * as PopoverPrimitive from "@radix-ui/react-popover"; import { CopyButton } from "../../ui/copy-button.js"; @@ -81,14 +87,147 @@ interface MessageOverflowAction { kind?: "copy"; } +// --------------------------------------------------------------------------- +// Width-aware layout: the row must never extend past the message it belongs +// to, so actions that don't fit the measured slot collapse into a trailing +// "⋯" menu instead of widening or wrapping the row. +// --------------------------------------------------------------------------- + +/** + * Pixel metrics mirrored from the Tailwind classes on the rendered controls: + * `size-5` desktop buttons, `size-7` touch buttons, `gap-2` between them. The + * fit computation needs them as numbers — keep in sync with the class + * constants below. + */ +const DESKTOP_ACTION_WIDTH_PX = 20; +const TOUCH_ACTION_WIDTH_PX = 28; +const ACTION_ROW_GAP_PX = 8; +/** + * The "⋯" trigger sits tighter to the last inline action than actions sit to + * each other (`-ml-1` on the trigger: 8px row gap minus 4px), so it reads as + * the row's continuation rather than one more action. + */ +const OVERFLOW_TRIGGER_GAP_PX = 4; +const OVERFLOW_TRIGGER_TIGHTEN_CLASS = "-ml-1"; +/** + * Breathing room the expanded touch row must leave inside the timeline column. + * Below it the row would butt against the column edge, so the popover is used + * instead. + */ +const EXPANDED_ROW_COMFORT_PX = 16; + +/** Width of `count` actions laid out in one row at the shared gap. */ +function actionRowWidth(count: number, actionWidth: number): number { + return count <= 0 ? 0 : count * actionWidth + (count - 1) * ACTION_ROW_GAP_PX; +} + +interface MessageActionRowLayout { + /** Leading actions rendered as direct buttons. */ + inlineCount: number; + /** Trailing actions collapsed into the "⋯" overflow menu. */ + overflowCount: number; +} + +export function computeMessageActionRowLayout({ + actionCount, + availableWidth, + actionWidth, + overflowTriggerWidth, +}: { + actionCount: number; + /** Measured slot width; undefined until the ResizeObserver first reports. */ + availableWidth: number | undefined; + actionWidth: number; + overflowTriggerWidth: number; +}): MessageActionRowLayout { + if (actionCount <= 0) { + return { inlineCount: 0, overflowCount: 0 }; + } + if (availableWidth === undefined) { + // Unmeasured (pre-observation frame, or an environment without a working + // ResizeObserver): render everything inline rather than nothing. The + // desktop bar is opacity-hidden until hover, so nothing flashes. + return { inlineCount: actionCount, overflowCount: 0 }; + } + if (actionRowWidth(actionCount, actionWidth) <= availableWidth) { + return { inlineCount: actionCount, overflowCount: 0 }; + } + // K inline actions need K-1 row gaps, then the trigger gap and the trigger: + // K·a + (K-1)·g + tg + t ≤ W ⇔ K ≤ (W - t - tg + g) / (a + g). + const inlineCount = Math.max( + 0, + Math.min( + actionCount - 1, + Math.floor( + (availableWidth - + overflowTriggerWidth - + OVERFLOW_TRIGGER_GAP_PX + + ACTION_ROW_GAP_PX) / + (actionWidth + ACTION_ROW_GAP_PX), + ), + ), + ); + return { inlineCount, overflowCount: actionCount - inlineCount }; +} + +/** + * Width of the action row's slot. A callback ref (rather than an object ref + * plus a mount effect) so the observer re-attaches when the bar swaps between + * its desktop and touch trees — an effect keyed on mount would keep observing + * the unmounted tree's detached node. + */ +function useMeasuredWidth( + resolveTarget?: (node: HTMLElement) => Element | null, +): { + measureRef: (node: HTMLElement | null) => void; + width: number | undefined; +} { + const [width, setWidth] = useState(undefined); + const observerRef = useRef(null); + const measureRef = useCallback( + (node: HTMLElement | null) => { + observerRef.current?.disconnect(); + observerRef.current = null; + if (node === null || typeof ResizeObserver === "undefined") { + return; + } + const target = resolveTarget ? resolveTarget(node) : node; + if (target === null) { + return; + } + const observer = new ResizeObserver(([entry]) => { + const inlineSize = + entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width; + // Floor so a fractional slot never admits a row one pixel too wide. + setWidth(Math.floor(inlineSize)); + }); + observer.observe(target); + observerRef.current = observer; + }, + [resolveTarget], + ); + return { measureRef, width }; +} + +/** + * The message column this row belongs to — the full timeline width, which for + * a right-aligned user message is much wider than its bubble. Module-level so + * the measuring callback ref stays stable across renders. + */ +const resolveMessageColumn = (node: HTMLElement): Element | null => + node.closest("[data-message-column]"); + interface MobileMessageOverflowPopoverProps { actions: readonly MessageOverflowAction[]; alignment: MessageActionBarProps["alignment"]; + /** Extra trigger classes (the tightened gap when inline actions precede it). */ + triggerClassName?: string; } function MobileMessageOverflowPopover({ actions, alignment, + triggerClassName, }: MobileMessageOverflowPopoverProps) { const [open, setOpen] = useState(false); const [copied, setCopied] = useState(false); @@ -109,7 +248,7 @@ function MobileMessageOverflowPopover({ + )} + + + {action.label} + + + ); +} + +/** Dropdown items shared by the "⋯" overflow menu and the mobile fallback. */ +function MessageActionMenuItems({ + actions, +}: { + actions: readonly MessageOverflowAction[]; +}) { + return actions.map((action) => ( + + {action.plugin ? ( + + ) : ( + + )); +} + /** * Hover-revealed footer of per-message actions. Renders an action only when it * is meaningful: copy when there is text, add-to-chat when a composer owns the * draft, and fork when its handler is supplied. `disabled` greys the fork * button (e.g. at the depth cap) while leaving copy and add-to-chat usable. + * + * The row tracks the width of the message it belongs to: its slot is measured + * with a ResizeObserver and actions that don't fit collapse into a trailing + * "⋯" menu, so the row never extends past the bubble or wraps. */ export function MessageActionBar({ messageText, @@ -225,13 +462,50 @@ export function MessageActionBar({ const [collisionBoundary, setCollisionBoundary] = useState< HTMLElement | undefined >(); + const { measureRef, width: availableWidth } = useMeasuredWidth(); + const { measureRef: measureColumnRef, width: columnWidth } = + useMeasuredWidth(resolveMessageColumn); + // Touch-only: the hidden actions revealed in place by the "⋯" trigger. + const [expanded, setExpanded] = useState(false); + const expandedRowRef = useRef(null); + const slotRef = useCallback( + (node: HTMLDivElement | null) => { + measureRef(node); + measureColumnRef(node); + }, + [measureRef, measureColumnRef], + ); + const desktopSlotRef = useCallback( + (node: HTMLDivElement | null) => { + slotRef(node); + setCollisionBoundary(findMessageActionTooltipCollisionBoundary(node)); + }, + [slotRef], + ); + useEffect(() => { + if (!expanded) return; + // Capture phase so a tap that also opens something else still collapses. + const handlePointerDown = (event: PointerEvent) => { + const row = expandedRowRef.current; + if (row && event.target instanceof Node && row.contains(event.target)) { + return; + } + setExpanded(false); + }; + document.addEventListener("pointerdown", handlePointerDown, true); + return () => + document.removeEventListener("pointerdown", handlePointerDown, true); + }, [expanded]); + // Bubble phase, so the action's own handler has already run. + const handleExpandedRowClick = (event: MouseEvent) => { + if ((event.target as HTMLElement | null)?.closest("button")) { + setExpanded(false); + } + }; const mobileDirectActionClass = mobileActionDisplay === "inline" ? MOBILE_INLINE_ACTION_CLASS : MOBILE_OVERFLOW_ACTION_CLASS; - const containerRef = useCallback((node: HTMLDivElement | null) => { - setCollisionBoundary(findMessageActionTooltipCollisionBoundary(node)); - }, []); const handleAddToChat = useCallback(() => { if (!onAddToChat) return; if (addToChatAttachments.length > 0) { @@ -240,7 +514,7 @@ export function MessageActionBar({ } onAddToChat(messageText); }, [addToChatAttachments, messageText, onAddToChat]); - const overflowActions: MessageOverflowAction[] = [ + const actions: MessageOverflowAction[] = [ ...(hasCopy ? [ { @@ -304,229 +578,168 @@ export function MessageActionBar({ ]; const useMobileOverflowPopover = isCompactViewport && isPointerCoarse; - if ( - !hasCopy && - !onEdit && - !hasAddToChat && - !onFork && - !onSendToMain && - pluginActions.length === 0 - ) { + if (actions.length === 0) { return null; } + const rowClass = cn( + ACTION_ROW_CLASS, + alignment === "end" ? "right-0" : "left-0", + ); + if (useMobileOverflowPopover) { // Touch phones: no hover, so no tooltips. Mounting the desktop bar here // would put five-plus hidden Radix tooltip trees per message into the // timeline for nothing; render only the mobile surface. + const layout = + mobileActionDisplay === "overflow" + ? { inlineCount: 0, overflowCount: actions.length } + : computeMessageActionRowLayout({ + actionCount: actions.length, + availableWidth, + actionWidth: TOUCH_ACTION_WIDTH_PX, + overflowTriggerWidth: TOUCH_ACTION_WIDTH_PX, + }); + // Tapping "⋯" reveals the hidden actions in place when the whole set fits + // the timeline column with room to spare — the row then reaches past a + // narrow bubble into the empty gutter beside it. When even the column is + // too tight the popover stays: it scrolls and can never clip. + const canExpandInline = + columnWidth !== undefined && + actionRowWidth(actions.length, TOUCH_ACTION_WIDTH_PX) <= + columnWidth - EXPANDED_ROW_COMFORT_PX; + if (expanded && canExpandInline) { + return ( +
+
+ +
+
+ ); + } return ( -
- {mobileActionDisplay === "overflow" ? ( - - ) : ( - - )} +
+
+ {layout.inlineCount > 0 ? ( + + ) : null} + {layout.overflowCount > 0 ? ( + canExpandInline ? ( + + ) : ( + 0 + ? OVERFLOW_TRIGGER_TIGHTEN_CLASS + : undefined + } + /> + ) + ) : null} +
); } + const layout = computeMessageActionRowLayout({ + actionCount: actions.length, + availableWidth, + actionWidth: DESKTOP_ACTION_WIDTH_PX, + overflowTriggerWidth: DESKTOP_ACTION_WIDTH_PX, + }); + return (
- {hasCopy ? ( - - - - - - Copy message - - - ) : null} - {onEdit ? ( - - - - - - Edit message - - - ) : null} - {hasAddToChat ? ( - - - - - - Add to chat - - - ) : null} - {onSendToMain ? ( - - - - - - Send to main thread - - - ) : null} - {onFork ? ( - - - - - + {actions.slice(0, layout.inlineCount).map((action) => ( + - Fork into new thread - - - ) : null} - {pluginActions.map((action) => ( - - - + + - - - - - {action.label} - - - ))} - {mobileActionDisplay === "overflow" ? ( - - - - - - {overflowActions.map((action) => ( - + + ) : null} + {mobileActionDisplay === "overflow" ? ( + // CSS-only fallback for a coarse-pointer compact viewport rendered + // through this tree (media queries and the JS hooks can disagree + // for a frame): all inline buttons hide and this trigger, holding + // every action, shows instead. + + + + + + + + + ) : null} +
); diff --git a/apps/app/src/components/thread/timeline/rows/AssistantMessage.stories.tsx b/apps/app/src/components/thread/timeline/rows/AssistantMessage.stories.tsx index 5fa8301ad5..38b465532f 100644 --- a/apps/app/src/components/thread/timeline/rows/AssistantMessage.stories.tsx +++ b/apps/app/src/components/thread/timeline/rows/AssistantMessage.stories.tsx @@ -237,6 +237,100 @@ export function Overview() { ); } +const overflowStoryPluginActions = [ + { + key: "story/summarize", + pluginId: null, + icon: "Sparkles", + label: "Summarize", + onSelect: noop, + }, + { + key: "story/translate", + pluginId: null, + icon: "Globe", + label: "Translate", + onSelect: noop, + }, + { + key: "story/save", + pluginId: null, + icon: "Bookmark", + label: "Save to notes", + onSelect: noop, + }, + { + key: "story/pin", + pluginId: null, + icon: "Pin", + label: "Pin message", + onSelect: noop, + }, + { + key: "story/share", + pluginId: null, + icon: "Share", + label: "Share", + onSelect: noop, + }, +]; + +/** + * QA fixtures for the width-tracked action row: the assistant row spans the + * message column, so its actions collapse into the "⋯" menu only when the + * column itself is narrower than the full set. + */ +export function ActionOverflow() { + return ( + + + +
+ +
+
+
+ +
+ +
+
+
+ ); +} + export function MobileActionsAndSelection() { return (
diff --git a/apps/app/src/components/thread/timeline/rows/UserMessage.stories.tsx b/apps/app/src/components/thread/timeline/rows/UserMessage.stories.tsx index 2dbe76b729..820282055c 100644 --- a/apps/app/src/components/thread/timeline/rows/UserMessage.stories.tsx +++ b/apps/app/src/components/thread/timeline/rows/UserMessage.stories.tsx @@ -1,4 +1,5 @@ import type { TimelineConversationAttachments } from "@bb/server-contract"; +import type { ThreadTimelinePluginMessageAction } from "@/components/thread/timeline/types"; import type { PromptMentionResource, PromptTextMention } from "@bb/domain"; import type { TimelineTitleLink } from "@bb/thread-view"; import { renderTemplate } from "@bb/templates"; @@ -924,6 +925,136 @@ export function Overview() { ); } +const overflowStoryActions: ThreadTimelinePluginMessageAction[] = [ + { + key: "story/summarize", + pluginId: null, + icon: "Sparkles", + label: "Summarize", + onSelect: handleStoryMessageEdit, + }, + { + key: "story/translate", + pluginId: null, + icon: "Globe", + label: "Translate", + onSelect: handleStoryMessageEdit, + }, + { + key: "story/save", + pluginId: null, + icon: "Bookmark", + label: "Save to notes", + onSelect: handleStoryMessageEdit, + }, +]; + +/** + * QA fixtures for the width-tracked action row: the row under each bubble must + * never extend past the bubble, collapsing trailing actions into a "⋯" menu + * when they don't fit. + */ +export function ActionOverflow() { + const promptDraft = useStoryPromptDraft(); + + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +} + export function ParentChildSystemMessages() { return ( From dad3503fa65ae55e62dbbc7a9b3238aefd57a488 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 21 Aug 2026 17:02:15 -0700 Subject: [PATCH 2/3] Align the message action row with the message text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row sat flush with the message's border box, so its outer glyph landed 4px from a bubble's edge — inside the bubble's 12px corner radius, reading as if it hung off the message. On the agent side the same slack pushed the glyph 4px inside the prose edge, indented the other way. Align the outer glyph edge to the message's text edge instead: inset the row by the bubble's padding and border minus the icon's hit-box slack (13px desktop, 11px touch), and pull prose rows out by the slack alone. The slot carries the inset as padding so the measured budget is the text width the row must fit, and the row carries a matching offset because an absolutely positioned child resolves `right` against the padding box. --- .../thread/timeline/MessageActionBar.tsx | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/apps/app/src/components/thread/timeline/MessageActionBar.tsx b/apps/app/src/components/thread/timeline/MessageActionBar.tsx index 5f7c3c22bb..8523768b09 100644 --- a/apps/app/src/components/thread/timeline/MessageActionBar.tsx +++ b/apps/app/src/components/thread/timeline/MessageActionBar.tsx @@ -351,6 +351,22 @@ const ACTION_ROW_CLASS = // neighbouring rows while it does. const ACTION_ROW_EXPANDED_CLASS = "absolute top-0 z-10 flex items-center gap-2"; +// Optical alignment: the row lines up with the message's *text*, not its +// border box. A bubble insets its text by 16px padding + 1px border, and each +// icon carries slack inside its larger hit box (a 20px box around a 12px glyph +// on desktop, 28px around 16px on touch), so the row is inset by the +// difference and the outer glyph edge lands on the text edge. Without it the +// glyph sits 4px from the bubble's edge — inside its 12px corner radius, so it +// reads as hanging off the message. +const BUBBLE_ALIGN_INSET_CLASS = "pr-[13px] max-md:pointer-coarse:pr-[11px]"; +// The row is absolutely positioned, so it resolves `right` against the slot's +// padding box — the padding above shrinks the measured budget but cannot move +// the row. Offset it by the same amount to place it. +const BUBBLE_ALIGN_OFFSET_CLASS = + "right-[13px] max-md:pointer-coarse:right-[11px]"; +// Prose rows have no bubble padding, so only the hit-box slack is corrected. +const PROSE_ALIGN_INSET_CLASS = "-ml-1 max-md:pointer-coarse:-ml-1.5"; + export function findMessageActionTooltipCollisionBoundary( node: HTMLElement | null, ): HTMLElement | undefined { @@ -584,7 +600,15 @@ export function MessageActionBar({ const rowClass = cn( ACTION_ROW_CLASS, - alignment === "end" ? "right-0" : "left-0", + alignment === "end" + ? BUBBLE_ALIGN_OFFSET_CLASS + : cn("left-0", PROSE_ALIGN_INSET_CLASS), + ); + // Padding on the slot, so the measured width is the text width the row has + // to fit into rather than the bubble's full border box. + const slotClass = cn( + "relative w-full", + alignment === "end" && BUBBLE_ALIGN_INSET_CLASS, ); if (useMobileOverflowPopover) { @@ -610,12 +634,14 @@ export function MessageActionBar({ columnWidth - EXPANDED_ROW_COMFORT_PX; if (expanded && canExpandInline) { return ( -
+
@@ -625,7 +651,7 @@ export function MessageActionBar({ ); } return ( -
+
{layout.inlineCount > 0 ? (
{actions.slice(0, layout.inlineCount).map((action) => ( From d3671a9debcab99cb51f7f1ebb47b48831738364 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 21 Aug 2026 21:19:31 -0700 Subject: [PATCH 3/3] Confirm a copy made from the revealed touch row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping Copy in the revealed touch row collapsed the row on the same click, unmounting CopyButton before its check could appear. The inline copy carries no toast either (`useClipboardCopy` defaults `successMessage` to null), so on every message but the most recent — where nothing is inline and the row is just the trigger — copying gave no confirmation at all. The popover this branch replaced kept its own check for two seconds. Dispatch the revealed row's copy through `copyToClipboardWithToast` and confirm on the trigger that replaces the row, matching the popover. --- .../thread/timeline/MessageActionBar.test.tsx | 36 +++++++++++ .../thread/timeline/MessageActionBar.tsx | 63 ++++++++++++++++--- 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/apps/app/src/components/thread/timeline/MessageActionBar.test.tsx b/apps/app/src/components/thread/timeline/MessageActionBar.test.tsx index 8f9ce351d3..6eb9086c74 100644 --- a/apps/app/src/components/thread/timeline/MessageActionBar.test.tsx +++ b/apps/app/src/components/thread/timeline/MessageActionBar.test.tsx @@ -559,6 +559,42 @@ describe("MessageActionBar", () => { ).toBeTruthy(); }); + it("confirms a copy made from the revealed touch row on the trigger", async () => { + mockMobileCoarsePointer(); + const resizeObserver = installControlledResizeObserver(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + render( +
+ +
, + ); + // Nothing fits the bubble, so the row is just the trigger; the column has + // room, so tapping it reveals the actions in place. + resizeObserver.reportWidths({ slot: 54, column: 358 }); + + fireEvent.click(screen.getByRole("button", { name: "Message actions" })); + fireEvent.click(screen.getByRole("button", { name: "Copy message" })); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith("Copy this answer."), + ); + // The row collapsed on the tap, so the check has to land on the trigger + // that replaced it — otherwise the copy is silent. + const trigger = await screen.findByRole("button", { + name: "Message actions", + }); + await waitFor(() => + expect(trigger.querySelector('[data-icon="Check"]')).not.toBeNull(), + ); + }); + it("keeps the popover when the column cannot fit the actions comfortably", () => { mockMobileCoarsePointer(); const resizeObserver = installControlledResizeObserver(); diff --git a/apps/app/src/components/thread/timeline/MessageActionBar.tsx b/apps/app/src/components/thread/timeline/MessageActionBar.tsx index 8523768b09..eb189eee5b 100644 --- a/apps/app/src/components/thread/timeline/MessageActionBar.tsx +++ b/apps/app/src/components/thread/timeline/MessageActionBar.tsx @@ -518,6 +518,15 @@ export function MessageActionBar({ setExpanded(false); } }; + // Copying from the expanded row collapses it, which unmounts the button + // before its own check can appear. Confirm on the trigger that replaces it, + // the same way the popover confirms on its trigger. + const [copiedFromRow, setCopiedFromRow] = useState(false); + useEffect(() => { + if (!copiedFromRow) return; + const timeoutId = window.setTimeout(() => setCopiedFromRow(false), 2000); + return () => window.clearTimeout(timeoutId); + }, [copiedFromRow]); const mobileDirectActionClass = mobileActionDisplay === "inline" ? MOBILE_INLINE_ACTION_CLASS @@ -645,7 +654,10 @@ export function MessageActionBar({ )} onClick={handleExpandedRowClick} > - + setCopiedFromRow(true)} + />
); @@ -671,7 +683,13 @@ export function MessageActionBar({ data-no-sidebar-swipe="" onClick={() => setExpanded(true)} > - + ) : ( void; }) { return actions.map((action) => action.kind === "copy" ? ( - + onCopied ? ( + + ) : ( + + ) ) : (