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..6eb9086c74 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,193 @@ 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("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(); + 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..eb189eee5b 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 +478,59 @@ 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); + } + }; + // 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 : 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 +539,7 @@ export function MessageActionBar({ } onAddToChat(messageText); }, [addToChatAttachments, messageText, onAddToChat]); - const overflowActions: MessageOverflowAction[] = [ + const actions: MessageOverflowAction[] = [ ...(hasCopy ? [ { @@ -304,229 +603,187 @@ 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" + ? 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) { // 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 ( +
+
+ setCopiedFromRow(true)} + /> +
+
+ ); + } 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} +
); @@ -538,17 +795,46 @@ export function MessageActionBar({ */ function MobileInlineActions({ actions, + onCopied, }: { actions: readonly MessageOverflowAction[]; + /** + * Set when this row is about to be unmounted by the copy click itself, so + * `CopyButton`'s own check would never be seen; the caller confirms instead. + */ + onCopied?: () => void; }) { return actions.map((action) => action.kind === "copy" ? ( - + onCopied ? ( + + ) : ( + + ) ) : (