From cc7c3f311f7b6aa325d2a0fdcca1e7bde0dc0cd1 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 22 Oct 2026 00:50:43 +0530 Subject: [PATCH 1/8] feat(message): add the Message family Message renders an agent, person or own message with an avatar, header, content, footer and send status; own messages sit in a bubble, and markdown comes only through MessageContent's render prop. MessageFold folds a long answer and keeps the reader's place when folding from inside it. MessageActions puts extras in an overflow menu without an optional peer, and MessageReactions, MessageReplies and useCopy cover reactions, reply summaries and copy feedback. A failed message offers a retry. --- e2e/fixtures.tsx | 130 ++++++ e2e/message.spec.ts | 181 ++++++++ src/hooks/use-copy.test.ts | 113 +++++ src/hooks/use-copy.ts | 60 +++ src/index.ts | 6 + src/ui/message-actions.tsx | 317 ++++++++++++++ src/ui/message-fold.test.tsx | 247 +++++++++++ src/ui/message-fold.tsx | 161 +++++++ src/ui/message-reactions.tsx | 94 +++++ src/ui/message-replies.tsx | 47 +++ src/ui/message.test.tsx | 702 +++++++++++++++++++++++++++++++ src/ui/message.tsx | 457 ++++++++++++++++++++ stories/chat/message.stories.tsx | 390 +++++++++++++++++ 13 files changed, 2905 insertions(+) create mode 100644 e2e/message.spec.ts create mode 100644 src/hooks/use-copy.test.ts create mode 100644 src/hooks/use-copy.ts create mode 100644 src/ui/message-actions.tsx create mode 100644 src/ui/message-fold.test.tsx create mode 100644 src/ui/message-fold.tsx create mode 100644 src/ui/message-reactions.tsx create mode 100644 src/ui/message-replies.tsx create mode 100644 src/ui/message.test.tsx create mode 100644 src/ui/message.tsx create mode 100644 stories/chat/message.stories.tsx diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index 88ceef00..36b3fdaa 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -55,6 +55,13 @@ import { type ToolApprovalRecord, ToolGroup, type PartToolBatch, + Message, + MessageAction, + MessageActionMenu, + MessageActions, + MessageContent, + MessageFold, + MessageFooter, } from "@corbits/react-ui"; import { CommandPalette, @@ -1069,6 +1076,121 @@ function ToolGroupApprovalScenario() { ); } +/** A long answer: 40 paragraphs of fixed-height lines, past the fold. */ +const FOLD_PARAGRAPHS = Array.from( + { length: 40 }, + (_, index) => `Paragraph ${index + 1} of a long answer.`, +); +const FOLD_LENGTH = 10_000; + +function FoldedAnswer() { + return ( + + + + {FOLD_PARAGRAPHS.map((text) => ( +

+ {text} +

+ ))} +
+
+
+ ); +} + +/** A long answer between other rows, in a plain scroll container. */ +function MessageFoldScenario() { + return ( +
+

Before

+ +

After

+
+ ); +} + +/** The same answer inside a conversation, which anchors its own scrolling. */ +function MessageFoldConversationScenario({ + policy, +}: { + policy: ConversationScrollPolicy; +}) { + return ( +
+ + + {historyRows("h", 10).map((row) => ( + +

Row {row.id}

+
+ ))} + + + + {historyRows("t", 10).map((row) => ( + +

Row {row.id}

+
+ ))} +
+
+
+ ); +} + +function ActionRow({ record }: { record: (event: string) => void }) { + return ( + + record("copy")} /> + + record("pin")} /> + record("link")} /> + record("report")} /> + + + ); +} + +/** An action row at the top of the page, and one at the foot of a short + * scroll container where a menu opening downwards would be clipped. */ +function MessageActionsScenario() { + const { log, record } = useEventLog(); + return ( +
+ + + + + + + +
+
+ + + + + +
+ {log} +
+ ); +} + export const scenarios: Record ReactNode> = { smoke: () => , "approval-card": () => , @@ -1113,4 +1235,12 @@ export const scenarios: Record ReactNode> = { "panel-tabs": () => , "tool-approval": () => , "tool-group-approval": () => , + "message-fold": () => , + "message-fold-settle": () => ( + + ), + "message-fold-stick": () => ( + + ), + "message-actions": () => , }; diff --git a/e2e/message.spec.ts b/e2e/message.spec.ts new file mode 100644 index 00000000..94567ec9 --- /dev/null +++ b/e2e/message.spec.ts @@ -0,0 +1,181 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + +const scroller = (page: Page) => page.getByTestId("scroller"); + +const toggle = (page: Page) => + page.locator("[data-slot='message-fold-toggle']"); + +/** How far the fold control sits below the top of the scroll container. */ +const toggleOffset = async (page: Page) => { + const box = await scroller(page).evaluate( + (element) => element.getBoundingClientRect().top, + ); + const top = await toggle(page).evaluate( + (element) => element.getBoundingClientRect().top, + ); + return top - box; +}; + +/** Waits for the container to stop moving. */ +const settled = async (page: Page) => { + let previous = Number.NaN; + await expect + .poll( + async () => { + const top = await scroller(page).evaluate( + (element) => element.scrollTop, + ); + const still = top === previous; + previous = top; + return still; + }, + { intervals: [100] }, + ) + .toBe(true); +}; + +/** Scrolls as the reader would, with the wheel, to put `contentTop` (a + * position inside the answer) at the top of the view. */ +const readInto = async (page: Page, contentTop: number) => { + const answerTop = await page + .getByTestId("long-answer") + .evaluate((element, scrollerTop) => { + const container = element.closest("[data-testid='scroller']"); + if (container === null) throw new Error("expected the scroller"); + return ( + element.getBoundingClientRect().top - + container.getBoundingClientRect().top + + container.scrollTop - + scrollerTop + ); + }, 0); + const current = await scroller(page).evaluate((element) => element.scrollTop); + const box = await scroller(page).boundingBox(); + if (box === null) throw new Error("expected the scroller on screen"); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.wheel(0, answerTop + contentTop - current); + await settled(page); +}; + +/** Opens the answer, reads into the middle of it, and folds it from there: + * the control must stay where it was on screen. */ +const foldFromInside = async (page: Page) => { + await toggle(page).click(); + await expect(toggle(page)).toHaveAttribute("aria-expanded", "true"); + await readInto(page, 900); + const before = await toggleOffset(page); + const height = await scroller(page).evaluate( + (element) => element.clientHeight, + ); + // Open, the control rides the bottom of the view while the answer is read. + expect(before).toBeGreaterThan(height / 2); + expect(before).toBeLessThan(height); + + await toggle(page).click(); + await expect(toggle(page)).toHaveAttribute("aria-expanded", "false"); + await settled(page); + const after = await toggleOffset(page); + expect(Math.abs(after - before)).toBeLessThanOrEqual(1); +}; + +test("folding an answer from inside it keeps the reader's place", async ({ + page, +}) => { + await page.goto("/message-fold"); + await expect(toggle(page)).toHaveText(/Show full answer/); + await foldFromInside(page); +}); + +for (const policy of ["settle", "stick"]) { + test(`folding inside a conversation (${policy}) keeps the reader's place`, async ({ + page, + }) => { + await page.goto(`/message-fold-${policy}`); + await expect(toggle(page)).toBeAttached(); + await foldFromInside(page); + }); +} + +const row = (page: Page, id: string) => page.getByTestId(id); +const trigger = (scope: Locator) => + scope.getByRole("button", { name: "More actions" }); +const menu = (scope: Locator) => + scope.getByRole("menu", { name: "More actions" }); +const item = (scope: Locator, name: string) => + scope.getByRole("menuitem", { name }); + +test("the overflow menu works from the keyboard and returns focus", async ({ + page, +}) => { + await page.goto("/message-actions"); + const top = row(page, "top"); + await trigger(top).focus(); + await page.keyboard.press("Enter"); + await expect(menu(top)).toBeVisible(); + await expect(trigger(top)).toHaveAttribute("aria-expanded", "true"); + await expect(item(top, "Pin")).toBeFocused(); + + await page.keyboard.press("ArrowDown"); + await expect(item(top, "Copy link")).toBeFocused(); + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("ArrowDown"); + await expect(item(top, "Pin")).toBeFocused(); + await page.keyboard.press("ArrowUp"); + await expect(item(top, "Report")).toBeFocused(); + await page.keyboard.press("Home"); + await expect(item(top, "Pin")).toBeFocused(); + await page.keyboard.press("End"); + await expect(item(top, "Report")).toBeFocused(); + + await page.keyboard.press("Escape"); + await expect(menu(top)).toHaveCount(0); + await expect(trigger(top)).toBeFocused(); + await expect(trigger(top)).toHaveAttribute("aria-expanded", "false"); +}); + +test("ArrowUp on the trigger opens the menu on its last item", async ({ + page, +}) => { + await page.goto("/message-actions"); + const top = row(page, "top"); + await trigger(top).focus(); + await page.keyboard.press("ArrowUp"); + await expect(item(top, "Report")).toBeFocused(); +}); + +test("choosing an item runs it, closes the menu and refocuses the trigger", async ({ + page, +}) => { + await page.goto("/message-actions"); + const top = row(page, "top"); + await trigger(top).focus(); + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("Enter"); + await expect(page.getByRole("status", { name: "Events" })).toHaveText("link"); + await expect(menu(top)).toHaveCount(0); + await expect(trigger(top)).toBeFocused(); +}); + +test("focus leaving the menu closes it", async ({ page }) => { + await page.goto("/message-actions"); + const top = row(page, "top"); + await trigger(top).click(); + await expect(item(top, "Pin")).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(menu(top)).toHaveCount(0); +}); + +test("a menu that would be clipped below opens above", async ({ page }) => { + await page.goto("/message-actions"); + const bottom = row(page, "bottom"); + await trigger(bottom).click(); + const content = menu(bottom); + await expect(content).toBeVisible(); + await expect(content).toHaveAttribute("data-side", "top"); + const clip = await page.getByTestId("clipped").boundingBox(); + const box = await content.boundingBox(); + if (clip === null || box === null) throw new Error("expected both boxes"); + expect(box.y).toBeGreaterThanOrEqual(clip.y); + expect(box.y + box.height).toBeLessThanOrEqual(clip.y + clip.height); +}); diff --git a/src/hooks/use-copy.test.ts b/src/hooks/use-copy.test.ts new file mode 100644 index 00000000..fc583740 --- /dev/null +++ b/src/hooks/use-copy.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { act } from "react"; + +import { renderHook } from "../test/render-hook.js"; +import { useCopy } from "./use-copy.js"; + +const original = Object.getOwnPropertyDescriptor(navigator, "clipboard"); + +function stubClipboard(writeText: (text: string) => Promise) { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); +} + +const wait = (ms: number) => + act(() => new Promise((resolve) => setTimeout(resolve, ms))); + +describe("useCopy", () => { + beforeEach(() => { + stubClipboard(() => Promise.resolve()); + }); + + afterEach(() => { + if (original === undefined) { + Reflect.deleteProperty(navigator, "clipboard"); + } else { + Object.defineProperty(navigator, "clipboard", original); + } + }); + + test("turns copied on after a copy and off after the timeout", async () => { + const writeText = mock((_text: string) => Promise.resolve()); + stubClipboard(writeText); + const hook = renderHook(() => useCopy({ timeout: 30 })); + expect(hook.result.current.copied).toBe(false); + + let written: boolean | undefined; + await act(async () => { + written = await hook.result.current.copy("hello"); + }); + expect(written).toBe(true); + expect(writeText).toHaveBeenCalledWith("hello"); + expect(hook.result.current.copied).toBe(true); + + await wait(50); + expect(hook.result.current.copied).toBe(false); + hook.unmount(); + }); + + test("copying again restarts the timer", async () => { + const hook = renderHook(() => useCopy({ timeout: 40 })); + await act(async () => { + await hook.result.current.copy("one"); + }); + await wait(25); + await act(async () => { + await hook.result.current.copy("two"); + }); + await wait(25); + expect(hook.result.current.copied).toBe(true); + await wait(30); + expect(hook.result.current.copied).toBe(false); + hook.unmount(); + }); + + test("a rejected clipboard write leaves copied false", async () => { + stubClipboard(() => Promise.reject(new Error("denied"))); + const hook = renderHook(() => useCopy()); + let written: boolean | undefined; + await act(async () => { + written = await hook.result.current.copy("hello"); + }); + expect(written).toBe(false); + expect(hook.result.current.copied).toBe(false); + hook.unmount(); + }); + + test("a failed copy turns off an earlier copied state", async () => { + let fail = false; + stubClipboard(() => + fail ? Promise.reject(new Error("denied")) : Promise.resolve(), + ); + const hook = renderHook(() => useCopy({ timeout: 1000 })); + await act(async () => { + await hook.result.current.copy("one"); + }); + expect(hook.result.current.copied).toBe(true); + fail = true; + await act(async () => { + await hook.result.current.copy("two"); + }); + expect(hook.result.current.copied).toBe(false); + hook.unmount(); + }); + + test("clears its timer on unmount", async () => { + const cleared = mock(clearTimeout); + const realClear = globalThis.clearTimeout; + globalThis.clearTimeout = cleared as unknown as typeof clearTimeout; + try { + const hook = renderHook(() => useCopy({ timeout: 1000 })); + await act(async () => { + await hook.result.current.copy("hello"); + }); + const before = cleared.mock.calls.length; + hook.unmount(); + expect(cleared.mock.calls.length).toBe(before + 1); + } finally { + globalThis.clearTimeout = realClear; + } + }); +}); diff --git a/src/hooks/use-copy.ts b/src/hooks/use-copy.ts new file mode 100644 index 00000000..6ebeeacc --- /dev/null +++ b/src/hooks/use-copy.ts @@ -0,0 +1,60 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export type UseCopyOptions = { + /** How long `copied` stays on after a successful copy, in ms. Default 2000. */ + readonly timeout?: number; +}; + +export type UseCopyResult = { + /** A copy succeeded within the last `timeout` ms. */ + readonly copied: boolean; + /** Writes `text` to the clipboard; resolves whether the write succeeded. */ + readonly copy: (text: string) => Promise; +}; + +/** + * Copy to the clipboard with a short-lived "copied" state for the control + * that did it. A failed or unavailable clipboard leaves `copied` off, so a + * control never claims a copy that did not happen. Copying again restarts the + * timer; unmounting clears it. + */ +export function useCopy({ + timeout = 2000, +}: UseCopyOptions = {}): UseCopyResult { + const [copied, setCopied] = useState(false); + const timerRef = useRef | null>(null); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + if (timerRef.current !== null) clearTimeout(timerRef.current); + timerRef.current = null; + }; + }, []); + + const copy = useCallback( + async (text: string) => { + let written = true; + try { + await navigator.clipboard.writeText(text); + } catch { + written = false; + } + if (!mountedRef.current) return written; + if (timerRef.current !== null) clearTimeout(timerRef.current); + timerRef.current = null; + setCopied(written); + if (!written) return false; + timerRef.current = setTimeout(() => { + timerRef.current = null; + setCopied(false); + }, timeout); + return true; + }, + [timeout], + ); + + return { copied, copy }; +} diff --git a/src/index.ts b/src/index.ts index 24026043..b3349ff5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ export * from "./hooks/use-prompt-input.js"; export * from "./hooks/use-resizable.js"; export * from "./hooks/use-tabs.js"; export * from "./hooks/use-tab-drag.js"; +export * from "./hooks/use-copy.js"; export * from "./lib/artifact.js"; export * from "./lib/chart-palette.js"; export * from "./lib/chat-message.js"; @@ -142,3 +143,8 @@ export * from "./ui/tool-approval.js"; export * from "./ui/tool-group.js"; export * from "./ui/reasoning.js"; export * from "./ui/activity.js"; +export * from "./ui/message.js"; +export * from "./ui/message-fold.js"; +export * from "./ui/message-actions.js"; +export * from "./ui/message-reactions.js"; +export * from "./ui/message-replies.js"; diff --git a/src/ui/message-actions.tsx b/src/ui/message-actions.tsx new file mode 100644 index 00000000..e286b726 --- /dev/null +++ b/src/ui/message-actions.tsx @@ -0,0 +1,317 @@ +"use client"; + +import { Ellipsis } from "lucide-react"; +import { + createContext, + useContext, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from "react"; +import type { ComponentProps, KeyboardEvent, ReactNode } from "react"; + +import { useDismissablePopover } from "../hooks/use-dismissable-popover.js"; +import { cn } from "../lib/utils.js"; + +type MenuContextValue = { + /** Closes the menu and returns focus to its trigger. */ + readonly close: () => void; +}; + +const MenuContext = createContext(null); + +export type MessageActionsProps = ComponentProps<"div">; + +/** + * The row of actions under a message: `MessageAction` buttons, then a + * `MessageActionMenu` for the extras. Named "Message actions" unless the host + * passes its own `aria-label`. + */ +export function MessageActions({ + "aria-label": label = "Message actions", + className, + ...props +}: MessageActionsProps) { + return ( +
+ ); +} + +export type MessageActionProps = Omit< + ComponentProps<"button">, + "aria-label" | "children" +> & { + /** + * What the action does. In the row it is the button's accessible name and + * tooltip; in a `MessageActionMenu` it is the item's visible text. + */ + readonly label: string; + /** The icon. Decorative: mark it `aria-hidden`. */ + readonly children?: ReactNode; +}; + +/** + * One action: an icon button in `MessageActions`, or an item (icon and + * label) inside a `MessageActionMenu`. Pass `aria-pressed` for an action that + * toggles, such as a vote. + */ +export function MessageAction({ + label, + className, + children, + onClick, + "aria-pressed": pressed, + ...props +}: MessageActionProps) { + const menu = useContext(MenuContext); + if (menu !== null) { + // A toggle in a menu is a checkbox item: menus have no pressed state. + const toggle = pressed !== undefined; + const checked = pressed === true || pressed === "true"; + return ( + + ); + } + return ( + + ); +} + +export type MessageActionMenuProps = Omit, "children"> & { + /** The trigger's accessible name and the menu's. Default "More actions". */ + readonly label?: string; + /** The trigger's icon. Defaults to an ellipsis. */ + readonly icon?: ReactNode; + /** Which side of the trigger the menu opens on. Default `bottom`. */ + readonly side?: "top" | "bottom"; + /** Which edge of the trigger the menu lines up with. Default `start`. */ + readonly align?: "start" | "end"; + /** Controlled open flag. Pair with `onOpenChange`. */ + readonly open?: boolean | undefined; + readonly onOpenChange?: ((open: boolean) => void) | undefined; + /** `MessageAction`s, shown as menu items. */ + readonly children: ReactNode; +}; + +function menuItems(menu: HTMLElement | null): HTMLElement[] { + if (menu === null) return []; + return [ + ...menu.querySelectorAll('[role^="menuitem"]:not(:disabled)'), + ]; +} + +/** The box a menu must fit inside: its nearest clipping ancestor, or the + * viewport. */ +function clipBounds(element: HTMLElement): { top: number; bottom: number } { + for ( + let node = element.parentElement; + node !== null; + node = node.parentElement + ) { + if (getComputedStyle(node).overflowY !== "visible") { + const rect = node.getBoundingClientRect(); + return { + top: Math.max(rect.top, 0), + bottom: Math.min(rect.bottom, window.innerHeight), + }; + } + } + return { top: 0, bottom: window.innerHeight }; +} + +/** + * The overflow menu at the end of `MessageActions`. Its items mount only + * while it is open, so a long conversation carries one button per message. + * + * Opening it moves focus to the first item (ArrowUp on the trigger: the + * last). Arrow keys, Home and End move between items; Escape closes it and + * returns focus to the trigger, as do choosing an item, Shift+Tab and + * pressing the trigger again; focus leaving it or a click outside closes it. + * It opens on `side` unless that side would clip it and the other has more + * room. + */ +export function MessageActionMenu({ + label = "More actions", + icon, + side = "bottom", + align = "start", + open: openProp, + onOpenChange, + className, + children, + ...props +}: MessageActionMenuProps) { + const { open, setOpen, rootRef, triggerRef, close } = useDismissablePopover< + HTMLDivElement, + HTMLButtonElement + >({ open: openProp, onOpenChange }); + const menuRef = useRef(null); + const focusOnOpen = useRef<"first" | "last">("first"); + // Set by a press on the trigger while open. Safari, and Firefox on macOS, + // do not focus a button on click, so the press blurs the menu with no + // `relatedTarget`; that blur must leave closing to the trigger's click. + const pressingTrigger = useRef(false); + const menuId = useId(); + const [placement, setPlacement] = useState(side); + + // Opens on `side` unless that would clip it and the other side has room. + useLayoutEffect(() => { + const menu = menuRef.current; + const root = rootRef.current; + if (!open || menu === null || root === null) { + setPlacement(side); + return; + } + const bounds = clipBounds(root); + const trigger = root.getBoundingClientRect(); + const height = menu.getBoundingClientRect().height; + const below = bounds.bottom - trigger.bottom; + const above = trigger.top - bounds.top; + if (side === "bottom" && below < height && above > below) { + setPlacement("top"); + } else if (side === "top" && above < height && below > above) { + setPlacement("bottom"); + } else { + setPlacement(side); + } + }, [open, side, rootRef]); + + useEffect(() => { + if (!open) return; + const items = menuItems(menuRef.current); + const target = focusOnOpen.current === "last" ? items.at(-1) : items[0]; + (target ?? menuRef.current)?.focus(); + focusOnOpen.current = "first"; + }, [open]); + + const onTriggerKeyDown = (event: KeyboardEvent) => { + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + event.preventDefault(); + focusOnOpen.current = event.key === "ArrowUp" ? "last" : "first"; + setOpen(true); + }; + + const onMenuKeyDown = (event: KeyboardEvent) => { + if (event.key === "Tab" && event.shiftKey) { + // Shift+Tab lands on the trigger, inside the root: close on the way. + event.preventDefault(); + close(); + return; + } + const items = menuItems(menuRef.current); + if (items.length === 0) return; + const current = items.findIndex((item) => item === document.activeElement); + let next: number | null = null; + if (event.key === "ArrowDown") next = (current + 1) % items.length; + else if (event.key === "ArrowUp") + next = current <= 0 ? items.length - 1 : current - 1; + else if (event.key === "Home") next = 0; + else if (event.key === "End") next = items.length - 1; + if (next === null) return; + event.preventDefault(); + items[next]?.focus(); + }; + + return ( +
{ + props.onBlur?.(event); + if (pressingTrigger.current) { + pressingTrigger.current = false; + return; + } + // Focus left for somewhere else (Tab, a screen reader's cursor). + const next = event.relatedTarget; + if (open && (next === null || !event.currentTarget.contains(next))) { + setOpen(false); + } + }} + > + { + pressingTrigger.current = open; + }} + onClick={() => { + pressingTrigger.current = false; + if (open) close(); + else setOpen(true); + }} + onKeyDown={onTriggerKeyDown} + > + {icon ?? } + + {open ? ( + + + + ) : null} +
+ ); +} diff --git a/src/ui/message-fold.test.tsx b/src/ui/message-fold.test.tsx new file mode 100644 index 00000000..dae7a308 --- /dev/null +++ b/src/ui/message-fold.test.tsx @@ -0,0 +1,247 @@ +import { act, useState } from "react"; +import type { ReactElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { describe, expect, mock, test } from "bun:test"; + +import { Message, MessageContent } from "./message.js"; +import { MessageFold } from "./message-fold.js"; + +type Mounted = { + container: HTMLElement; + rerender: (node: ReactElement) => void; + unmount: () => void; +}; + +function render(node: ReactElement): Mounted { + const container = document.createElement("div"); + document.body.appendChild(container); + let root!: Root; + act(() => { + root = createRoot(container); + root.render(node); + }); + return { + container, + rerender: (next) => act(() => root.render(next)), + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +function toggle(container: HTMLElement): HTMLButtonElement | null { + return container.querySelector( + "[data-slot='message-fold-toggle']", + ); +} + +function state(container: HTMLElement): string | undefined { + return container.querySelector("[data-slot='message-fold']") + ?.dataset["state"]; +} + +describe("MessageFold", () => { + test("under the threshold renders its children and no toggle", () => { + const mounted = render( + +

Short answer

+
, + ); + expect(mounted.container.textContent).toBe("Short answer"); + expect(toggle(mounted.container)).toBeNull(); + expect(state(mounted.container)).toBeUndefined(); + mounted.unmount(); + }); + + test("over the threshold folds by default and toggles", () => { + const onOpenChange = mock((_open: boolean) => {}); + const mounted = render( + +

Long answer

+
, + ); + const button = toggle(mounted.container); + expect(state(mounted.container)).toBe("folded"); + expect(button?.getAttribute("aria-expanded")).toBe("false"); + expect(button?.textContent).toContain("Show full answer"); + const body = mounted.container.querySelector( + "[data-slot='message-fold-body']", + ); + expect(button?.getAttribute("aria-controls")).toBe(body?.id ?? ""); + + act(() => button?.click()); + expect(state(mounted.container)).toBe("open"); + expect(button?.getAttribute("aria-expanded")).toBe("true"); + expect(button?.textContent).toContain("Show less"); + expect(onOpenChange).toHaveBeenLastCalledWith(true); + + act(() => button?.click()); + expect(state(mounted.container)).toBe("folded"); + expect(onOpenChange).toHaveBeenLastCalledWith(false); + mounted.unmount(); + }); + + test("the default threshold is about a thousand words", () => { + const short = render( + +

At the limit

+
, + ); + const long = render( + +

Past it

+
, + ); + expect(toggle(short.container)).toBeNull(); + expect(toggle(long.container)).not.toBeNull(); + short.unmount(); + long.unmount(); + }); + + test("streaming content never folds", () => { + const mounted = render( + +

Still arriving

+
, + ); + expect(toggle(mounted.container)).toBeNull(); + mounted.unmount(); + }); + + test("reads streaming from the enclosing Message", () => { + const mounted = render( + + + + + , + ); + expect(toggle(mounted.container)).toBeNull(); + mounted.unmount(); + }); + + test("an answer that finished streaming in view stays open", () => { + const at = (status: "streaming" | "sent") => ( + + + + + + ); + const mounted = render(at("streaming")); + mounted.rerender(at("sent")); + expect(state(mounted.container)).toBe("open"); + mounted.unmount(); + }); + + test("honours a controlled open flag", () => { + const mounted = render( + +

Long answer

+
, + ); + expect(state(mounted.container)).toBe("open"); + act(() => toggle(mounted.container)?.click()); + expect(state(mounted.container)).toBe("open"); + mounted.unmount(); + }); + + test("a close the controlled host ignored does not move the page later", async () => { + const page = document.scrollingElement as HTMLElement; + const at = (open: boolean) => ( + {}} + > +

Long answer

+
+ ); + const mounted = render(at(true)); + const button = toggle(mounted.container)!; + let top = 500; + button.getBoundingClientRect = () => ({ top }) as DOMRect; + page.scrollTop = 1000; + act(() => button.click()); + await act(() => new Promise((resolve) => requestAnimationFrame(resolve))); + top = 100; + mounted.rerender(at(false)); + expect(page.scrollTop).toBe(1000); + page.scrollTop = 0; + mounted.unmount(); + }); + + test("focus moving into the folded part unfolds it", () => { + const mounted = render( + + A link further down + , + ); + expect(state(mounted.container)).toBe("folded"); + act(() => mounted.container.querySelector("a")?.focus()); + expect(state(mounted.container)).toBe("open"); + mounted.unmount(); + }); + + test("focusing the toggle does not unfold it", () => { + const mounted = render( + +

Long answer

+
, + ); + act(() => toggle(mounted.container)?.focus()); + expect(state(mounted.container)).toBe("folded"); + mounted.unmount(); + }); +}); + +function Counter() { + const [count, setCount] = useState(0); + return ( + + ); +} + +describe("MessageFold keeps its children mounted", () => { + test("when a long answer finishes streaming", () => { + const at = (status: "streaming" | "sent") => ( + + + + + + ); + const mounted = render(at("streaming")); + const before = mounted.container.querySelector("button")!; + act(() => before.click()); + act(() => before.focus()); + mounted.rerender(at("sent")); + const after = mounted.container.querySelector("[data-count]")!; + expect(after).toBe(before); + expect(after.getAttribute("data-count")).toBe("1"); + expect(document.activeElement).toBe(before); + mounted.unmount(); + }); + + test("when the answer grows past the threshold", () => { + const at = (length: number) => ( + + + + ); + const mounted = render(at(50)); + const before = mounted.container.querySelector("button")!; + act(() => before.click()); + mounted.rerender(at(150)); + expect(mounted.container.querySelector("[data-count]")).toBe(before); + mounted.unmount(); + }); +}); diff --git a/src/ui/message-fold.tsx b/src/ui/message-fold.tsx new file mode 100644 index 00000000..581dc085 --- /dev/null +++ b/src/ui/message-fold.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { ChevronDown, ChevronUp } from "lucide-react"; +import { useId, useLayoutEffect, useRef } from "react"; +import type { ComponentProps } from "react"; + +import { useControllableState } from "../hooks/use-controllable-state.js"; +import { cn } from "../lib/utils.js"; +import { useMessageContext } from "./message.js"; + +/** Answers longer than this many characters (about a thousand words) fold. */ +export const MESSAGE_FOLD_THRESHOLD = 6_000; + +export type MessageFoldProps = ComponentProps<"div"> & { + /** The answer's length in characters, e.g. `text.length`. */ + readonly length: number; + /** Folds only past this length. Default `MESSAGE_FOLD_THRESHOLD`. */ + readonly threshold?: number; + /** + * The answer is still arriving: it never folds. Defaults to whether the + * enclosing `Message` is `streaming`. + */ + readonly streaming?: boolean | undefined; + /** Controlled open flag. Pair with `onOpenChange`. */ + readonly open?: boolean | undefined; + /** Seeds the open flag when `open` is not supplied. Default `false`. */ + readonly defaultOpen?: boolean; + readonly onOpenChange?: ((open: boolean) => void) | undefined; +}; + +function scrollContainerOf(element: HTMLElement): HTMLElement | null { + for ( + let node = element.parentElement; + node !== null; + node = node.parentElement + ) { + const { overflowY } = getComputedStyle(node); + if ( + (overflowY === "auto" || overflowY === "scroll") && + node.scrollHeight > node.clientHeight + ) { + return node; + } + } + const page = document.scrollingElement; + return page instanceof HTMLElement ? page : null; +} + +/** + * Folds a very long answer to its opening, with a control to show the rest. + * Under `threshold`, or while streaming, it renders its children as they are. + * Folded by default, except an answer that was streaming when it mounted, + * which stays open once it finishes. + * + * Open, the "Show less" control rides the bottom of the view while the answer + * is read. Folding from there keeps the reader's place: the control stays + * where it was on screen and the nearest scroll container moves instead. + * Moving focus into the folded part (a link, by Tab) unfolds it, so focus is + * never hidden. + */ +export function MessageFold({ + length, + threshold = MESSAGE_FOLD_THRESHOLD, + streaming: streamingProp, + open: openProp, + defaultOpen = false, + onOpenChange, + className, + children, + onFocus, + ...props +}: MessageFoldProps) { + const message = useMessageContext(); + const streaming = streamingProp ?? message?.status === "streaming"; + const [open, setOpen] = useControllableState({ + value: openProp, + // An answer the reader watched arrive starts open: it must not fold + // under them the moment it finishes. + defaultValue: defaultOpen || streaming, + onChange: onOpenChange, + name: "MessageFold", + }); + const toggleRef = useRef(null); + const foldedFrom = useRef(null); + const bodyId = useId(); + const foldable = !streaming && length > threshold; + + useLayoutEffect(() => { + const before = foldedFrom.current; + const toggle = toggleRef.current; + if (before === null || open || toggle === null) return; + foldedFrom.current = null; + const scroller = scrollContainerOf(toggle); + if (scroller === null) return; + const delta = toggle.getBoundingClientRect().top - before; + scroller.scrollTop += delta; + }, [open]); + + const folded = foldable && !open; + + // One element shape in every state, so the children are never remounted + // when the answer becomes foldable (streaming ends, or it grows past the + // threshold): only attributes change and the toggle comes and goes beside + // the body. + return ( +
{ + onFocus?.(event); + if (folded && toggleRef.current?.contains(event.target) !== true) { + setOpen(true); + } + }} + {...props} + > +
+ {children} +
+ {foldable ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/src/ui/message-reactions.tsx b/src/ui/message-reactions.tsx new file mode 100644 index 00000000..ea155130 --- /dev/null +++ b/src/ui/message-reactions.tsx @@ -0,0 +1,94 @@ +"use client"; + +import type { ComponentProps } from "react"; + +import type { ConversationReaction } from "../lib/conversation.js"; +import { cn } from "../lib/utils.js"; + +export type MessageReactionsProps = Omit, "children"> & { + readonly reactions: readonly ConversationReaction[]; + /** + * Toggles the viewer's reaction. Without it the reactions are shown, not + * offered. + */ + readonly onReact?: ((emoji: string) => void) | undefined; +}; + +function reactionLabel({ emoji, count, mine }: ConversationReaction): string { + const people = count === 1 ? "1 reaction" : `${count} reactions`; + return mine ? `${emoji}, ${people}, including yours` : `${emoji}, ${people}`; +} + +/** + * The reactions on a message, one chip per emoji with its count. The + * viewer's own are outlined and bold, and pressed for assistive tech. Renders + * nothing when there are none. + */ +export function MessageReactions({ + reactions, + onReact, + "aria-label": label = "Reactions", + className, + ...props +}: MessageReactionsProps) { + if (reactions.length === 0) return null; + const chip = + "inline-flex h-6 items-center gap-1 rounded-full border px-2 text-sm leading-none"; + const tone = (mine: boolean) => + mine + ? "border-border-strong bg-muted text-foreground" + : "border-transparent bg-muted text-muted-foreground"; + return ( +
+ {reactions.map((reaction) => { + const content = ( + <> + {reaction.emoji} + + {reaction.count} + + + ); + return onReact === undefined ? ( + + {content} + + ) : ( + + ); + })} +
+ ); +} diff --git a/src/ui/message-replies.tsx b/src/ui/message-replies.tsx new file mode 100644 index 00000000..726f4fd9 --- /dev/null +++ b/src/ui/message-replies.tsx @@ -0,0 +1,47 @@ +import { ChevronRight } from "lucide-react"; +import type { ComponentProps } from "react"; + +import type { ConversationRepliesSummary } from "../lib/conversation.js"; +import { formatRelativeTime } from "../lib/relative-time.js"; +import { cn } from "../lib/utils.js"; + +export type MessageRepliesProps = Omit, "children"> & { + readonly replies: ConversationRepliesSummary; + /** Reference time for "Last reply …", in ms. Defaults to now. */ + readonly now?: number | undefined; +}; + +/** + * The replies to a message: how many and when the last one came, as a + * button that opens the thread (the host's `onClick`). Renders nothing when + * there are no replies. + */ +export function MessageReplies({ + replies, + now, + className, + ...props +}: MessageRepliesProps) { + if (replies.count <= 0) return null; + const count = replies.count === 1 ? "1 reply" : `${replies.count} replies`; + return ( + + ); +} diff --git a/src/ui/message.test.tsx b/src/ui/message.test.tsx new file mode 100644 index 00000000..16499e7f --- /dev/null +++ b/src/ui/message.test.tsx @@ -0,0 +1,702 @@ +import { act, createRef } from "react"; +import type { ReactElement } from "react"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { describe, expect, mock, test } from "bun:test"; + +import { + Message, + MessageAvatar, + MessageContent, + MessageFooter, + MessageHeader, + MessageSendStatus, +} from "./message.js"; +import { + MessageAction, + MessageActionMenu, + MessageActions, +} from "./message-actions.js"; +import { MessageReactions } from "./message-reactions.js"; +import { MessageReplies } from "./message-replies.js"; + +type Mounted = { + container: HTMLElement; + rerender: (node: ReactElement) => void; + unmount: () => void; +}; + +function render(node: ReactElement): Mounted { + const container = document.createElement("div"); + document.body.appendChild(container); + let root!: Root; + act(() => { + root = createRoot(container); + root.render(node); + }); + return { + container, + rerender: (next) => act(() => root.render(next)), + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +function slot(container: HTMLElement, name: string): HTMLElement { + const element = container.querySelector(`[data-slot='${name}']`); + if (element === null) throw new Error(`expected a ${name}`); + return element; +} + +function button(container: HTMLElement, name: string): HTMLButtonElement { + const match = [...container.querySelectorAll("button")].find( + (element) => + (element.getAttribute("aria-label") ?? element.textContent?.trim()) === + name, + ); + if (match === undefined) throw new Error(`expected a ${name} button`); + return match; +} + +/** Message's own status region. */ +function region(container: HTMLElement): Element | null { + return container.querySelector("[data-slot='message'] > [role='status']"); +} + +/** The visible error on a failed message's status line. */ +function visibleReason(container: HTMLElement): HTMLElement | null { + return container.querySelector( + "[data-slot='message-send-status'] > span", + ); +} + +const NOW = Date.parse("2026-03-04T12:00:00.000Z"); + +describe("Message", () => { + test("marks its root with who it is from and its status", () => { + const mounted = render( + + + , + ); + const root = slot(mounted.container, "message"); + expect(root.tagName).toBe("ARTICLE"); + expect(root.dataset["from"]).toBe("agent"); + expect(root.dataset["status"]).toBe("streaming"); + expect( + slot(mounted.container, "message-content").getAttribute("aria-busy"), + ).toBe("true"); + mounted.unmount(); + }); + + test("forwards a ref to the article", () => { + const ref = createRef(); + const mounted = render(); + expect(ref.current).toBe(slot(mounted.container, "message")); + mounted.unmount(); + }); + + test("a sending message says it is on its way", () => { + const mounted = render( + + + + , + ); + expect(slot(mounted.container, "message-send-status").textContent).toBe( + "Sending…", + ); + expect(mounted.container.querySelector("button")).toBeNull(); + mounted.unmount(); + }); + + test("a sent message shows no status", () => { + const mounted = render( + + + + , + ); + expect( + mounted.container.querySelector("[data-slot='message-send-status']"), + ).toBeNull(); + mounted.unmount(); + }); + + test("a failed message shows its error and a retry that calls onRetry once", () => { + const onRetry = mock(() => {}); + const mounted = render( + + + + , + ); + const status = slot(mounted.container, "message-send-status"); + expect(status.textContent).toContain( + "Couldn't send. Check your connection.", + ); + act(() => button(mounted.container, "Retry").click()); + expect(onRetry).toHaveBeenCalledTimes(1); + mounted.unmount(); + }); + + test("a failed message without onRetry offers no retry", () => { + const mounted = render( + + + , + ); + expect( + slot(mounted.container, "message-send-status").textContent, + ).toContain("Not sent."); + expect(mounted.container.querySelector("button")).toBeNull(); + mounted.unmount(); + }); + + test("a failed message shows its error and retry without a placed MessageSendStatus", () => { + const onRetry = mock(() => {}); + const mounted = render( + + + , + ); + const status = slot(mounted.container, "message-send-status"); + expect(status.textContent).toContain("Couldn't send"); + act(() => button(mounted.container, "Retry").click()); + expect(onRetry).toHaveBeenCalledTimes(1); + mounted.unmount(); + }); + + test("a sending message says so without a placed MessageSendStatus", () => { + const mounted = render( + + + , + ); + expect(slot(mounted.container, "message-send-status").textContent).toBe( + "Sending…", + ); + mounted.unmount(); + }); + + test("a placed MessageSendStatus is the only one, where the host put it", () => { + const mounted = render( + + <> + + + + , + ); + const lines = mounted.container.querySelectorAll( + "[data-slot='message-send-status']", + ); + expect(lines).toHaveLength(1); + expect(slot(mounted.container, "message").firstElementChild).toBe( + lines[0] ?? null, + ); + mounted.unmount(); + }); + + test("a failure after mount is announced once; one mounted failed is not", () => { + const at = (status: "sending" | "failed") => ( + + + + ); + + const history = render(at("failed")); + expect(region(history.container)?.textContent).toBe(""); + expect(visibleReason(history.container)?.hasAttribute("aria-hidden")).toBe( + false, + ); + history.unmount(); + + const live = render(at("sending")); + expect(region(live.container)?.textContent).toBe(""); + live.rerender(at("failed")); + expect(region(live.container)?.textContent).toBe("Couldn't send"); + // The region holds exactly the visible text, which is then hidden. + expect(visibleReason(live.container)?.getAttribute("aria-hidden")).toBe( + "true", + ); + live.unmount(); + }); + + test("an error that arrives after the failure is what is announced", () => { + const at = (status: "sending" | "failed", error?: string) => ( + + + + ); + const mounted = render(at("sending")); + mounted.rerender(at("failed")); + expect(region(mounted.container)?.textContent).toBe("Not sent."); + mounted.rerender(at("failed", "Network down")); + expect(region(mounted.container)?.textContent).toBe("Network down"); + expect(visibleReason(mounted.container)?.textContent).toBe("Network down"); + expect(mounted.container.textContent).not.toContain("Not sent: "); + mounted.unmount(); + }); + + test("a line placed in the footer replaces the added one and is announced once", () => { + const onRetry = mock(() => {}); + const at = (status: "sending" | "failed") => ( + + + + + + + ); + const mounted = render(at("sending")); + mounted.rerender(at("failed")); + const lines = mounted.container.querySelectorAll( + "[data-slot='message-send-status']", + ); + expect(lines).toHaveLength(1); + expect(lines[0]?.parentElement?.dataset["slot"]).toBe("message-footer"); + const retries = [...mounted.container.querySelectorAll("button")].filter( + (element) => element.textContent === "Retry", + ); + expect(retries).toHaveLength(1); + const regions = [...mounted.container.querySelectorAll("[role='status']")]; + expect(regions.map((element) => element.textContent)).toEqual([ + "Couldn't send", + ]); + mounted.unmount(); + }); + + test("a line the host mounts only once failed still announces a failure it saw", () => { + const at = (status: "sending" | "failed") => ( + + + {status === "failed" ? : null} + + ); + const mounted = render(at("sending")); + mounted.rerender(at("failed")); + expect( + mounted.container.querySelectorAll("[data-slot='message-send-status']"), + ).toHaveLength(1); + expect(region(mounted.container)?.textContent).toBe("Couldn't send"); + mounted.unmount(); + }); + + test("a nested placed line never paints beside the added one", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + // flushSync returns after the commit and its layout effects, before the + // browser could paint: what is in the DOM now is the first frame. + flushSync(() => + root.render( + + + + + + , + ), + ); + expect( + container.querySelectorAll("[data-slot='message-send-status']"), + ).toHaveLength(1); + act(() => root.unmount()); + container.remove(); + }); + + test("inside a log a failure is left to the log to announce", () => { + const at = (status: "sending" | "failed") => ( +
+ + + +
+ ); + const mounted = render(at("sending")); + mounted.rerender(at("failed")); + expect(region(mounted.container)?.textContent).toBe(""); + expect( + slot(mounted.container, "message-send-status").textContent, + ).toContain("Couldn't send"); + expect(visibleReason(mounted.container)?.hasAttribute("aria-hidden")).toBe( + false, + ); + mounted.unmount(); + }); + + test("is named by its header's author name", () => { + const mounted = render( + + + + , + ); + const root = slot(mounted.container, "message"); + const id = root.getAttribute("aria-labelledby") ?? ""; + expect(id).not.toBe(""); + expect(mounted.container.querySelector(`[id='${id}']`)?.textContent).toBe( + "Ada", + ); + mounted.unmount(); + }); + + test("a header nested in a wrapper still names it", () => { + function Top() { + return ( +
+ +
+ ); + } + const mounted = render( + + + + , + ); + const id = + slot(mounted.container, "message").getAttribute("aria-labelledby") ?? ""; + expect(id).not.toBe(""); + expect(mounted.container.querySelector(`[id='${id}']`)?.textContent).toBe( + "Ada", + ); + mounted.unmount(); + }); + + test("without a header, or with the host's own label, the host's label names it", () => { + const bare = render( + + + , + ); + const labelled = render( + + + , + ); + for (const mounted of [bare, labelled]) { + const root = slot(mounted.container, "message"); + expect(root.hasAttribute("aria-labelledby")).toBe(false); + } + expect(slot(bare.container, "message").getAttribute("aria-label")).toBe( + "You", + ); + expect(slot(labelled.container, "message").getAttribute("aria-label")).toBe( + "Ada, 5 min ago", + ); + bare.unmount(); + labelled.unmount(); + }); + + test("declares a row only for parts that take one", () => { + const rows = (node: ReactElement) => { + const mounted = render(node); + const value = slot(mounted.container, "message").style.gridTemplateRows; + mounted.unmount(); + return value; + }; + expect( + rows( + + + + + + , + ), + ).toBe("repeat(2, auto)"); + expect( + rows( + + + , + ), + ).toBe("repeat(2, auto)"); + }); + + test("MessageContent passes its text through render", () => { + const renderText = mock((text: string) => ( + {text.toUpperCase()} + )); + const mounted = render( + + + , + ); + expect(renderText).toHaveBeenCalledWith("**bold**"); + const content = slot(mounted.container, "message-content"); + expect(content.querySelector("strong")?.textContent).toBe("**BOLD**"); + mounted.unmount(); + }); + + test("MessageContent shows plain text without render, children without text", () => { + const mounted = render( + + + + custom + + , + ); + const [plain, custom] = mounted.container.querySelectorAll( + "[data-slot='message-content']", + ); + expect(plain?.textContent).toBe("not html"); + expect(plain?.querySelector("b")).toBeNull(); + expect(custom?.querySelector("em")?.textContent).toBe("custom"); + mounted.unmount(); + }); + + test("only own messages put their content in a bubble", () => { + const own = render( + + + , + ); + const agent = render( + + + , + ); + expect(slot(own.container, "message-content").className).toContain( + "bg-muted", + ); + expect(slot(agent.container, "message-content").className).not.toContain( + "bg-muted", + ); + own.unmount(); + agent.unmount(); + }); + + test("the avatar is labelled with the author and toned by who it is from", () => { + const mounted = render( + + + , + ); + const avatar = mounted.container.querySelector("[role='img']"); + expect(avatar?.getAttribute("aria-label")).toBe("Ada Lovelace"); + expect(avatar?.textContent).toBe("AL"); + expect(avatar?.className).toContain("bg-primary"); + mounted.unmount(); + }); + + test("the header shows the name, role and a relative time", () => { + const mounted = render( + + + , + ); + const header = slot(mounted.container, "message-header"); + expect(header.textContent).toContain("Ada"); + expect(header.textContent).toContain("Assistant"); + const time = header.querySelector("time"); + expect(time?.getAttribute("dateTime")).toBe("2026-03-04T11:55:00.000Z"); + expect(time?.textContent).toContain("5"); + mounted.unmount(); + }); + + test("parts throw outside a Message", () => { + const original = console.error; + console.error = () => {}; + try { + expect(() => render()).toThrow( + "MessageFooter must be inside a Message.", + ); + } finally { + console.error = original; + } + }); +}); + +describe("MessageActions", () => { + test("an action is an icon button named by its label", () => { + const onClick = mock(() => {}); + const mounted = render( + + + + , + ); + const group = slot(mounted.container, "message-actions"); + expect(group.getAttribute("role")).toBe("group"); + expect(group.getAttribute("aria-label")).toBe("Message actions"); + act(() => button(mounted.container, "Copy").click()); + expect(onClick).toHaveBeenCalledTimes(1); + expect( + button(mounted.container, "Good response").getAttribute("aria-pressed"), + ).toBe("true"); + mounted.unmount(); + }); + + test("the overflow menu mounts its items only while open", () => { + const onPin = mock(() => {}); + const mounted = render( + + + + + + , + ); + const trigger = button(mounted.container, "More actions"); + expect(trigger.getAttribute("aria-haspopup")).toBe("menu"); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + expect(mounted.container.querySelector("[role='menu']")).toBeNull(); + + act(() => trigger.click()); + const menu = mounted.container.querySelector("[role='menu']"); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + expect(trigger.getAttribute("aria-controls")).toBe(menu?.id ?? ""); + const pin = menu?.querySelector("[role='menuitem']"); + expect(pin?.textContent).toBe("Pin"); + expect(document.activeElement).toBe(pin ?? null); + const toggle = menu?.querySelector("[role='menuitemcheckbox']"); + expect(toggle?.getAttribute("aria-checked")).toBe("false"); + + act(() => (pin as HTMLButtonElement).click()); + expect(onPin).toHaveBeenCalledTimes(1); + expect(mounted.container.querySelector("[role='menu']")).toBeNull(); + expect(document.activeElement).toBe(trigger); + mounted.unmount(); + }); +}); + +describe("MessageActionMenu", () => { + const menu = () => ( + + + + + ); + + test("pressing the trigger closes an open menu when the click does not focus it", () => { + const mounted = render(menu()); + const trigger = button(mounted.container, "More actions"); + act(() => trigger.click()); + const item = + mounted.container.querySelector("[role='menuitem']"); + expect(document.activeElement).toBe(item); + // Safari: the press blurs the item with no relatedTarget and leaves the + // trigger unfocused, then the click lands. + act(() => { + trigger.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + item?.blur(); + }); + act(() => trigger.click()); + expect(mounted.container.querySelector("[role='menu']")).toBeNull(); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + expect(document.activeElement).toBe(trigger); + mounted.unmount(); + }); + + test("Shift+Tab from an item closes it and returns to the trigger", () => { + const mounted = render(menu()); + const trigger = button(mounted.container, "More actions"); + act(() => trigger.click()); + const item = + mounted.container.querySelector("[role='menuitem']"); + act(() => { + item?.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Tab", + shiftKey: true, + bubbles: true, + }), + ); + }); + expect(mounted.container.querySelector("[role='menu']")).toBeNull(); + expect(document.activeElement).toBe(trigger); + mounted.unmount(); + }); +}); + +describe("MessageReactions", () => { + const reactions = [ + { emoji: "🎉", count: 2, mine: true }, + { emoji: "👀", count: 1, mine: false }, + ]; + + test("each reaction toggles through onReact and says whether it is yours", () => { + const onReact = mock((_emoji: string) => {}); + const mounted = render( + , + ); + const buttons = mounted.container.querySelectorAll("button"); + expect(buttons[0]?.getAttribute("aria-pressed")).toBe("true"); + expect(buttons[0]?.getAttribute("aria-label")).toBe( + "🎉, 2 reactions, including yours", + ); + expect(buttons[1]?.getAttribute("aria-pressed")).toBe("false"); + act(() => buttons[1]?.click()); + expect(onReact).toHaveBeenCalledWith("👀"); + mounted.unmount(); + }); + + test("without onReact the reactions are shown, not offered", () => { + const mounted = render(); + expect(mounted.container.querySelector("button")).toBeNull(); + expect( + mounted.container.querySelectorAll("[data-slot='message-reaction']"), + ).toHaveLength(2); + mounted.unmount(); + }); + + test("renders nothing with no reactions", () => { + const mounted = render(); + expect(mounted.container.innerHTML).toBe(""); + mounted.unmount(); + }); +}); + +describe("MessageReplies", () => { + test("shows the count and when the last reply came", () => { + const onClick = mock(() => {}); + const mounted = render( + , + ); + const replies = slot(mounted.container, "message-replies"); + expect(replies.textContent).toContain("2 replies"); + expect(replies.textContent).toContain("Last reply"); + expect(replies.querySelector("time")?.textContent).toContain("10"); + act(() => replies.click()); + expect(onClick).toHaveBeenCalledTimes(1); + mounted.unmount(); + }); + + test("renders nothing with no replies", () => { + const mounted = render( + , + ); + expect(mounted.container.innerHTML).toBe(""); + mounted.unmount(); + }); +}); diff --git a/src/ui/message.tsx b/src/ui/message.tsx new file mode 100644 index 00000000..e322013a --- /dev/null +++ b/src/ui/message.tsx @@ -0,0 +1,457 @@ +"use client"; + +import { CircleAlert } from "lucide-react"; +import { + Children, + Fragment, + createContext, + isValidElement, + useCallback, + useContext, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { ComponentProps, ReactElement, ReactNode } from "react"; + +import type { ConversationMessageStatus } from "../lib/conversation.js"; +import { formatRelativeTime } from "../lib/relative-time.js"; +import { cn } from "../lib/utils.js"; +import { Avatar, type AvatarProps } from "./avatar.js"; +import { Button } from "./button.js"; + +/** + * Who a message is from, as the viewer sees it: an `agent`, another + * `person`, or the viewer themself (`own`). + */ +export type MessageFrom = "agent" | "person" | "own"; + +export type MessageContextValue = { + readonly from: MessageFrom; + readonly status: ConversationMessageStatus; + readonly error: string | undefined; + readonly onRetry: (() => void) | undefined; + /** The id `MessageHeader` gives the author's name, naming the message. */ + readonly nameId: string; + /** + * Whether a failure the reader saw happen is being announced: the visible + * error is then left to the announcement so it is not read twice. + */ + readonly announcing: boolean; + /** Called on mount by a placed `MessageSendStatus`; returns its unregister. */ + readonly registerSendStatus: () => () => void; + /** Called on mount by a `MessageHeader`; returns its unregister. */ + readonly registerHeader: () => () => void; +}; + +const MessageContext = createContext(null); + +/** + * The enclosing `Message`'s props, or `null` outside one: for parts a host + * builds itself, and for parts that can stand alone (`MessageFold`) to read + * as defaults. + */ +export function useMessageContext(): MessageContextValue | null { + return useContext(MessageContext); +} + +function useMessage(part: string): MessageContextValue { + const context = useContext(MessageContext); + if (context === null) throw new Error(`${part} must be inside a Message.`); + return context; +} + +export type MessageProps = ComponentProps<"article"> & { + readonly from: MessageFrom; + /** Defaults to `sent`. */ + readonly status?: ConversationMessageStatus; + /** Why a `failed` message failed, shown by its send status. */ + readonly error?: string | undefined; + /** Offered as a Retry control by its send status on a `failed` message. */ + readonly onRetry?: (() => void) | undefined; +}; + +/** The parts as laid out in the grid: children with fragments opened. */ +function partsOf(children: ReactNode): ReactElement[] { + return Children.toArray(children).flatMap((child) => { + if (!isValidElement(child)) return []; + if (child.type === Fragment) { + return partsOf((child.props as { children?: ReactNode }).children); + } + return [child]; + }); +} + +const shownStatus = (status: ConversationMessageStatus) => + status === "sending" || status === "failed"; + +const reasonOf = (error: string | undefined) => error ?? "Not sent."; + +/** A ref-counted flag a part sets for as long as it is mounted. */ +function useRegistry(): readonly [boolean, () => () => void] { + const [count, setCount] = useState(0); + const register = useCallback(() => { + setCount((value) => value + 1); + return () => setCount((value) => value - 1); + }, []); + return [count > 0, register] as const; +} + +/** + * One message, built from parts: `MessageAvatar`, `MessageHeader`, + * `MessageContent`, `MessageSendStatus`, `MessageFooter`, and anything else the + * host puts in. Every part is optional. Parts stack in order; a + * `MessageAvatar` takes a column of its own beside them, at the start, or at + * the end for the viewer's own messages. + * + * Only the viewer's own messages sit in a bubble, at the end of the line. An + * agent's or person's message is set on the page with no container, so a long + * answer reads as text. + * + * A `sending` or `failed` message always shows its send status: Message + * adds the line after the other parts unless a `MessageSendStatus` is placed + * anywhere inside it, which then sets where it goes. + * + * A failure the reader sees happen (the message was shown sending or sent + * first) is announced once, politely: it does not cut off what they are + * hearing, and the Retry is right there. The error is announced as it + * stands, so one that arrives later replaces "Not sent.". A message that + * mounts already failed (history) is not announced, and neither is one inside + * a `role="log"` (`ConversationContent`), which reads the new line itself. + * + * The message is named by its `MessageHeader`'s author name when a header is + * anywhere inside it and the host passes no `aria-label` or + * `aria-labelledby` of its own. Leaving out `MessageHeader` leaves out the + * name: pass an `aria-label` (author and time, say, or "You") so a screen + * reader still hears it. + */ +export function Message({ + from, + status = "sent", + error, + onRetry, + className, + style, + children, + "aria-label": label, + "aria-labelledby": labelledBy, + ...props +}: MessageProps) { + const nameId = useId(); + // Parts register from a layout effect, so a placed status line or header + // anywhere inside is known before the first paint. + const [placesStatus, registerSendStatus] = useRegistry(); + const [hasHeader, registerHeader] = useRegistry(); + const regionRef = useRef(null); + const [announcing, setAnnouncing] = useState(false); + // Whether the reader has seen this message not failed since it last + // failed: only then is a failure news to them. + const seenUnfailed = useRef(status !== "failed"); + + useEffect(() => { + if (status !== "failed") { + seenUnfailed.current = true; + setAnnouncing(false); + return; + } + if (!seenUnfailed.current) return; + seenUnfailed.current = false; + if (regionRef.current?.closest("[role='log']") != null) return; + setAnnouncing(true); + }, [status]); + + const context = useMemo( + () => ({ + from, + status, + error, + onRetry, + nameId, + announcing, + registerSendStatus, + registerHeader, + }), + [ + from, + status, + error, + onRetry, + nameId, + announcing, + registerSendStatus, + registerHeader, + ], + ); + const parts = partsOf(children); + const addsStatus = !placesStatus && shownStatus(status); + // The avatar spans every row, so the rows must be declared: an implicit + // row is outside a `1 / -1` span. Only parts that take a row count; an + // empty row would still get the row gap. Parts beyond the count (from a + // nested component) still stack in the column; the avatar stops short. + const rows = + parts.filter( + (part) => + part.type !== MessageAvatar && + (part.type !== MessageSendStatus || shownStatus(status)), + ).length + (addsStatus ? 1 : 0); + return ( + +
[data-slot=message-avatar]]:grid-cols-[minmax(0,1fr)_auto] has-[>[data-slot=message-avatar]]:*:col-start-1" + : "has-[>[data-slot=message-avatar]]:grid-cols-[auto_minmax(0,1fr)] has-[>[data-slot=message-avatar]]:*:col-start-2", + className, + )} + style={{ + gridTemplateRows: `repeat(${Math.max(rows, 1)}, auto)`, + ...style, + }} + {...props} + > + {children} + {addsStatus ? : null} + {status === "sent" && !announcing ? null : ( + + {announcing ? reasonOf(error) : ""} + + )} +
+
+ ); +} + +export type MessageSendStatusProps = ComponentProps<"div">; + +/** + * Where a send stands: "Sending…" while `sending`; the error and a Retry + * control (when the message has `onRetry`) once `failed`. Nothing otherwise. + * `Message` adds the line itself; place this anywhere inside it (in a + * `MessageFooter`, say) to choose where it goes instead. + */ +export function MessageSendStatus(props: MessageSendStatusProps) { + const { registerSendStatus } = useMessage("MessageSendStatus"); + useLayoutEffect(registerSendStatus, [registerSendStatus]); + return ; +} + +function SendStatusLine({ className, ...props }: MessageSendStatusProps) { + const { status, error, onRetry, announcing } = + useMessage("MessageSendStatus"); + if (!shownStatus(status)) return null; + return ( +
+ {status === "sending" ? ( + "Sending…" + ) : ( + <> + {/* While announced, Message's status region holds this exact + text: hidden here so it is not read twice. */} + + + {reasonOf(error)} + + {onRetry === undefined ? null : ( + + )} + + )} +
+ ); +} + +export type MessageAvatarProps = Omit & { + /** The author's name: the avatar's accessible label. */ + readonly name: string; + /** Up to two characters. Defaults to the first letters of `name`. */ + readonly initials?: string; +}; + +function initialsOf(name: string): string { + return name + .trim() + .split(/\s+/) + .slice(0, 2) + .map((word) => word.charAt(0)) + .join(""); +} + +/** + * The author's avatar, in a column of its own beside the message. An agent's takes the agent + * tone by default; a person's stays neutral. + */ +export function MessageAvatar({ + name, + initials, + tone, + shape = "circle", + className, + ...props +}: MessageAvatarProps) { + const { from } = useMessage("MessageAvatar"); + return ( + + + + ); +} + +export type MessageHeaderProps = Omit, "role"> & { + readonly name: ReactNode; + /** One quiet line beside the name, e.g. an agent's role. */ + readonly role?: ReactNode; + /** ISO timestamp of the message. */ + readonly at?: string | undefined; + /** Reference time for the relative label, in ms. Defaults to now. */ + readonly now?: number | undefined; + /** Formats `at`. Defaults to a relative time ("5 min ago"). */ + readonly formatTime?: ((at: string) => string) | undefined; +}; + +/** The author's name, an optional role and when the message was sent. */ +export function MessageHeader({ + name, + role, + at, + now, + formatTime, + className, + children, + ...props +}: MessageHeaderProps) { + const message = useMessageContext(); + const register = message?.registerHeader; + useLayoutEffect(() => register?.(), [register]); + return ( +
+ + {name} + + {role === undefined ? null : {role}} + {at === undefined ? null : ( + + )} + {children} +
+ ); +} + +export type MessageContentProps = ComponentProps<"div"> & { + /** The message's text. Without `render` it is shown as plain text. */ + readonly text?: string | undefined; + /** + * Renders `text`, e.g. as markdown with the host's own renderer and + * sanitiser. This package renders no markdown itself. + */ + readonly render?: ((text: string) => ReactNode) | undefined; +}; + +/** + * The message body: `text` (through `render` when given) or, without `text`, + * the children. The viewer's own message is a bubble; anyone else's is plain + * text on the page. Busy while the message streams. + */ +export function MessageContent({ + text, + render, + className, + children, + ...props +}: MessageContentProps) { + const { from, status } = useMessage("MessageContent"); + const plain = text !== undefined && render === undefined; + return ( +
+ {text === undefined + ? children + : render === undefined + ? text + : render(text)} +
+ ); +} + +export type MessageFooterProps = ComponentProps<"div">; + +/** One line under the message: reactions, replies, actions. */ +export function MessageFooter({ className, ...props }: MessageFooterProps) { + const { from } = useMessage("MessageFooter"); + return ( +
+ ); +} diff --git a/stories/chat/message.stories.tsx b/stories/chat/message.stories.tsx new file mode 100644 index 00000000..4ae08e50 --- /dev/null +++ b/stories/chat/message.stories.tsx @@ -0,0 +1,390 @@ +import { + Check, + Copy, + Link, + Pin, + Reply, + RotateCw, + SmilePlus, + ThumbsDown, + ThumbsUp, +} from "lucide-react"; +import { Fragment, useState } from "react"; +import type { ReactNode } from "react"; + +import { useCopy } from "../../src/hooks/use-copy.js"; +import type { + ConversationMessage, + ConversationReaction, +} from "../../src/lib/conversation.js"; +import { + Message, + MessageAvatar, + MessageContent, + MessageFooter, + MessageHeader, + MessageSendStatus, + type MessageFrom, +} from "../../src/ui/message.js"; +import { + MessageAction, + MessageActionMenu, + MessageActions, +} from "../../src/ui/message-actions.js"; +import { MessageFold } from "../../src/ui/message-fold.js"; +import { MessageReactions } from "../../src/ui/message-reactions.js"; +import { MessageReplies } from "../../src/ui/message-replies.js"; +import { + SAMPLE_NOW, + failedSend, + shortChat, + withThread, + you, +} from "./samples/conversations.js"; + +export default { title: "Chat / Message" }; + +const NOW = Date.parse(SAMPLE_NOW); + +/** + * A stand-in markdown renderer: paragraphs, `- ` lists and `**bold**`. A + * real host passes its own renderer and sanitiser through `render`. + */ +function markdown(text: string): ReactNode { + const inline = (line: string) => + line + .split(/(\*\*[^*]+\*\*)/) + .map((piece, index) => + piece.startsWith("**") && piece.endsWith("**") ? ( + {piece.slice(2, -2)} + ) : ( + {piece} + ), + ); + return ( +
+ {text.split(/\n{2,}/).map((block, index) => { + const lines = block.split("\n"); + if (lines.every((line) => line.startsWith("- "))) { + return ( +
    + {lines.map((line, item) => ( +
  • {inline(line.slice(2))}
  • + ))} +
+ ); + } + return

{inline(block)}

; + })} +
+ ); +} + +function textOf(message: ConversationMessage): string { + return message.parts + .flatMap((part) => (part.kind === "text" ? [part.text] : [])) + .join("\n\n"); +} + +function find( + messages: readonly ConversationMessage[], + id: string, +): ConversationMessage { + const message = messages.find((candidate) => candidate.id === id); + if (message === undefined) throw new Error(`no sample message ${id}`); + return message; +} + +function fromOf(message: ConversationMessage): MessageFrom { + if (message.author.id === you.id) return "own"; + return message.author.kind; +} + +function Column({ children }: { readonly children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +/** A sample message with the usual parts; each can be left out. */ +function SampleMessage({ + message, + avatar = true, + header = true, + footer, + onRetry, +}: { + readonly message: ConversationMessage; + readonly avatar?: boolean; + readonly header?: boolean; + readonly footer?: ReactNode; + readonly onRetry?: () => void; +}) { + const from = fromOf(message); + const own = from === "own"; + return ( + + {avatar && !own ? : null} + {header && !own ? ( + + ) : null} + + + {footer === undefined ? null : {footer}} + + ); +} + +export function Agent() { + return ( + + + + ); +} + +export function Person() { + return ( + + + + ); +} + +export function Own() { + return ( + + + + + ); +} + +/** One person and one agent: no avatars or names, the answer is just text. */ +export function NoAvatars() { + return ( + + {shortChat.messages.map((message) => ( + + ))} + + ); +} + +function ActionRow({ text }: { readonly text: string }) { + const { copied, copy } = useCopy(); + const [vote, setVote] = useState<"up" | "down" | null>(null); + const [pinned, setPinned] = useState(false); + const [log, setLog] = useState(""); + return ( + <> + + void copy(text)} + > + {copied ? : } + + setVote(vote === "up" ? null : "up")} + > + + + setVote(vote === "down" ? null : "down")} + > + + + setLog("Regenerate")}> + + + + setPinned(!pinned)} + > + + + setLog("Copy link")}> + + + setLog("Reply in thread")} + > + + + + + {log} + + ); +} + +export function Actions() { + const message = find(shortChat.messages, "short-4"); + return ( + + } + /> + + ); +} + +const PICKER = ["👍", "🎉", "👀", "✅"]; + +function toggleReaction( + reactions: readonly ConversationReaction[], + emoji: string, +): readonly ConversationReaction[] { + const existing = reactions.find((reaction) => reaction.emoji === emoji); + if (existing === undefined) { + return [...reactions, { emoji, count: 1, mine: true }]; + } + return reactions.flatMap((reaction) => { + if (reaction.emoji !== emoji) return [reaction]; + const count = reaction.count + (reaction.mine ? -1 : 1); + return count === 0 ? [] : [{ emoji, count, mine: !reaction.mine }]; + }); +} + +export function Reactions() { + const message = find(withThread.messages, "thread-2"); + const [reactions, setReactions] = useState(message.reactions ?? []); + const react = (emoji: string) => + setReactions((current) => toggleReaction(current, emoji)); + return ( + + + + + } + > + {PICKER.map((emoji) => ( + react(emoji)} + /> + ))} + + + + } + /> + + } + /> + + ); +} + +export function Replies() { + const message = find(withThread.messages, "thread-2"); + const [opened, setOpened] = useState(false); + return ( + + setOpened(!opened)} + /> + ) + } + /> + + ); +} + +export function Failed() { + const [retries, setRetries] = useState(0); + return ( + + {failedSend.messages.map((message) => ( + setRetries(retries + 1)} + /> + ))} + + Retries: {retries} + + + ); +} + +const SECTION = + "The workflow ran its steps in order and wrote each result to the shared folder before moving on. Two steps waited on an approval, one call was retried after a slow response, and the export finished a little later than usual because it paged through every result before filtering. Nothing failed, and the totals match the previous run within a percent."; + +const LONG_ANSWER = Array.from( + { length: 24 }, + (_, index) => `**Part ${index + 1}.** ${SECTION}`, +).join("\n\n"); + +export function VeryLongAnswer() { + return ( +
+ + + + + + + + + + +
+ ); +} From 07ecdf6ee02806f9c22a39c6732124295d8a2cbc Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Tue, 27 Oct 2026 14:14:25 +0545 Subject: [PATCH 2/8] fix(message): merge a host ref into the action menu and keep the fold A ref passed to MessageActionMenu replaced the root ref, so a click outside no longer closed the menu and it no longer flipped above a trigger near the bottom. The host's ref is now merged into the root. Folding a long answer kept the reader's place only when the host committed the close in the same frame as the click. The fold now reads the toggle's position with getSnapshotBeforeUpdate, after React decides to fold and before the DOM changes, so the place is kept however and whenever the close lands. --- src/ui/message-actions.tsx | 14 ++- src/ui/message-fold.test.tsx | 80 +++++++++++++++ src/ui/message-fold.tsx | 162 ++++++++++++++++++------------- src/ui/message.test.tsx | 51 ++++++++++ stories/chat/message.stories.tsx | 2 +- 5 files changed, 241 insertions(+), 68 deletions(-) diff --git a/src/ui/message-actions.tsx b/src/ui/message-actions.tsx index e286b726..c9158ced 100644 --- a/src/ui/message-actions.tsx +++ b/src/ui/message-actions.tsx @@ -3,6 +3,7 @@ import { Ellipsis } from "lucide-react"; import { createContext, + useCallback, useContext, useEffect, useId, @@ -181,12 +182,23 @@ export function MessageActionMenu({ onOpenChange, className, children, + ref, ...props }: MessageActionMenuProps) { const { open, setOpen, rootRef, triggerRef, close } = useDismissablePopover< HTMLDivElement, HTMLButtonElement >({ open: openProp, onOpenChange }); + // The host's ref must not replace the root ref: outside-click dismissal + // and the side flip both measure the root. + const setRoot = useCallback( + (element: HTMLDivElement | null) => { + rootRef.current = element; + if (typeof ref === "function") ref(element); + else if (ref != null) ref.current = element; + }, + [ref, rootRef], + ); const menuRef = useRef(null); const focusOnOpen = useRef<"first" | "last">("first"); // Set by a press on the trigger while open. Safari, and Firefox on macOS, @@ -256,7 +268,7 @@ export function MessageActionMenu({ return (
{ mounted.unmount(); }); + test("a close the controlled host commits after a frame keeps the reader's place", async () => { + const page = document.scrollingElement as HTMLElement; + function Host() { + const [open, setOpen] = useState(true); + return ( + { + setTimeout(() => setOpen(next), 60); + }} + > +

Long answer

+
+ ); + } + const mounted = render(); + const button = toggle(mounted.container)!; + // Open, the toggle sits at 500; folded, 400px higher. + button.getBoundingClientRect = () => + ({ top: state(mounted.container) === "open" ? 500 : 100 }) as DOMRect; + page.scrollTop = 1000; + act(() => button.click()); + await act(() => new Promise((resolve) => setTimeout(resolve, 80))); + expect(state(mounted.container)).toBe("folded"); + expect(page.scrollTop).toBe(600); + page.scrollTop = 0; + mounted.unmount(); + }); + + test("a host that folds it without the toggle keeps the reader's place too", () => { + const page = document.scrollingElement as HTMLElement; + const at = (open: boolean) => ( + {}} + > +

Long answer

+
+ ); + const mounted = render(at(true)); + const button = toggle(mounted.container)!; + button.getBoundingClientRect = () => + ({ top: state(mounted.container) === "open" ? 500 : 100 }) as DOMRect; + page.scrollTop = 1000; + mounted.rerender(at(false)); + expect(page.scrollTop).toBe(600); + page.scrollTop = 0; + mounted.unmount(); + }); + + test("a fold below the visible area does not move the page", () => { + const page = document.scrollingElement as HTMLElement; + const at = (open: boolean) => ( + {}} + > +

Long answer

+
+ ); + const mounted = render(at(true)); + const button = toggle(mounted.container)!; + const below = window.innerHeight + 200; + button.getBoundingClientRect = () => + ({ + top: state(mounted.container) === "open" ? below : below - 400, + }) as DOMRect; + page.scrollTop = 1000; + mounted.rerender(at(false)); + expect(page.scrollTop).toBe(1000); + page.scrollTop = 0; + mounted.unmount(); + }); + test("focus moving into the folded part unfolds it", () => { const mounted = render( diff --git a/src/ui/message-fold.tsx b/src/ui/message-fold.tsx index 581dc085..270e71d5 100644 --- a/src/ui/message-fold.tsx +++ b/src/ui/message-fold.tsx @@ -1,8 +1,8 @@ "use client"; import { ChevronDown, ChevronUp } from "lucide-react"; -import { useId, useLayoutEffect, useRef } from "react"; -import type { ComponentProps } from "react"; +import { Component, useId, useRef } from "react"; +import type { ComponentProps, ReactNode, RefObject } from "react"; import { useControllableState } from "../hooks/use-controllable-state.js"; import { cn } from "../lib/utils.js"; @@ -46,6 +46,58 @@ function scrollContainerOf(element: HTMLElement): HTMLElement | null { return page instanceof HTMLElement ? page : null; } +type FoldAnchorProps = { + readonly open: boolean; + readonly toggle: RefObject; + readonly children: ReactNode; +}; + +type FoldAnchorSnapshot = { + readonly scroller: HTMLElement; + readonly top: number; +}; + +/** + * Keeps the fold control where it was on screen when the answer folds, so + * the reader's place survives the content above it shrinking. The control's + * position has to be read after React decides to fold but before it changes + * the DOM, and `getSnapshotBeforeUpdate` is the only React API that runs at + * that point; no hook does. It works however the close arrives: a click, a + * controlled host committing late, or a host folding it some other way. + */ +class FoldAnchor extends Component { + override getSnapshotBeforeUpdate( + previous: FoldAnchorProps, + ): FoldAnchorSnapshot | null { + const toggle = this.props.toggle.current; + if (!previous.open || this.props.open || toggle === null) return null; + const scroller = scrollContainerOf(toggle); + if (scroller === null) return null; + const top = toggle.getBoundingClientRect().top; + const bottom = + scroller === document.scrollingElement + ? window.innerHeight + : scroller.getBoundingClientRect().bottom; + // Below the visible area nothing the reader sees moves: leave it be. + return top > bottom ? null : { scroller, top }; + } + + override componentDidUpdate( + _previous: FoldAnchorProps, + _state: unknown, + snapshot: FoldAnchorSnapshot | null, + ) { + const toggle = this.props.toggle.current; + if (snapshot === null || toggle === null) return; + snapshot.scroller.scrollTop += + toggle.getBoundingClientRect().top - snapshot.top; + } + + override render() { + return this.props.children; + } +} + /** * Folds a very long answer to its opening, with a control to show the rest. * Under `threshold`, or while streaming, it renders its children as they are. @@ -81,21 +133,8 @@ export function MessageFold({ name: "MessageFold", }); const toggleRef = useRef(null); - const foldedFrom = useRef(null); const bodyId = useId(); const foldable = !streaming && length > threshold; - - useLayoutEffect(() => { - const before = foldedFrom.current; - const toggle = toggleRef.current; - if (before === null || open || toggle === null) return; - foldedFrom.current = null; - const scroller = scrollContainerOf(toggle); - if (scroller === null) return; - const delta = toggle.getBoundingClientRect().top - before; - scroller.scrollTop += delta; - }, [open]); - const folded = foldable && !open; // One element shape in every state, so the children are never remounted @@ -103,59 +142,50 @@ export function MessageFold({ // threshold): only attributes change and the toggle comes and goes beside // the body. return ( -
{ - onFocus?.(event); - if (folded && toggleRef.current?.contains(event.target) !== true) { - setOpen(true); - } - }} - {...props} - > +
{ + onFocus?.(event); + if (folded && toggleRef.current?.contains(event.target) !== true) { + setOpen(true); + } + }} + {...props} > - {children} -
- {foldable ? ( -
- +
+ {children}
- ) : null} -
+ {foldable ? ( +
+ +
+ ) : null} +
+ ); } diff --git a/src/ui/message.test.tsx b/src/ui/message.test.tsx index 16499e7f..46c9658d 100644 --- a/src/ui/message.test.tsx +++ b/src/ui/message.test.tsx @@ -633,6 +633,57 @@ describe("MessageActionMenu", () => { expect(document.activeElement).toBe(trigger); mounted.unmount(); }); + + test("a click outside closes it when the host passes a ref", () => { + const ref = createRef(); + const mounted = render( + + + , + ); + expect(ref.current).toBe( + slot(mounted.container, "message-action-menu"), + ); + act(() => button(mounted.container, "More actions").click()); + expect(mounted.container.querySelector("[role='menu']")).not.toBeNull(); + act(() => { + document.body.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true }), + ); + }); + expect(mounted.container.querySelector("[role='menu']")).toBeNull(); + mounted.unmount(); + }); + + test("it flips above a trigger near the bottom when the host passes a ref", () => { + const realRect = HTMLElement.prototype.getBoundingClientRect; + HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) { + const height = window.innerHeight; + if (this.dataset["slot"] === "message-action-menu") { + return { top: height - 40, bottom: height - 12, height: 28 } as DOMRect; + } + if (this.dataset["slot"] === "message-action-menu-content") { + return { top: 0, bottom: 200, height: 200 } as DOMRect; + } + return realRect.call(this); + }; + try { + const mounted = render( + ()}> + + , + ); + act(() => button(mounted.container, "More actions").click()); + expect( + mounted.container + .querySelector("[role='menu']") + ?.getAttribute("data-side"), + ).toBe("top"); + mounted.unmount(); + } finally { + HTMLElement.prototype.getBoundingClientRect = realRect; + } + }); }); describe("MessageReactions", () => { diff --git a/stories/chat/message.stories.tsx b/stories/chat/message.stories.tsx index 4ae08e50..fadbeca6 100644 --- a/stories/chat/message.stories.tsx +++ b/stories/chat/message.stories.tsx @@ -369,7 +369,7 @@ const LONG_ANSWER = Array.from( export function VeryLongAnswer() { return ( -
+
From 375710fc00c3ba9a596b46248990e7c60a366314 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 16:53:18 +0545 Subject: [PATCH 3/8] fix(message): keep copy and menu focus on the latest request useCopy let whichever clipboard write settled last set `copied`, so a slow earlier write could show "Copied" after a newer copy failed, or clear the state after a newer one succeeded. Each copy now takes a number, and only the latest one updates `copied` and the timer. The action menu kept the "focus the last item" request from an ArrowUp that a controlled host ignored, so a later open, by a click or by the host, landed on the last item. The request is now state and is dropped in the render that finds the menu still closed, as the prompt's add menu does, so any later open starts on the first item. Also note in the menu's docs that a click outside closes it without taking focus back, as the other popovers do. --- src/hooks/use-copy.test.ts | 56 +++++++++++++++++++++++++++ src/hooks/use-copy.ts | 7 +++- src/ui/message-actions.tsx | 23 ++++++++---- src/ui/message.test.tsx | 77 +++++++++++++++++++++++++++++++++++++- 4 files changed, 152 insertions(+), 11 deletions(-) diff --git a/src/hooks/use-copy.test.ts b/src/hooks/use-copy.test.ts index fc583740..e46ce4ef 100644 --- a/src/hooks/use-copy.test.ts +++ b/src/hooks/use-copy.test.ts @@ -13,6 +13,16 @@ function stubClipboard(writeText: (text: string) => Promise) { }); } +/** Stubs a clipboard whose writes settle only when the test says so. */ +function holdClipboard() { + const pending: { resolve: () => void; reject: (error: Error) => void }[] = []; + stubClipboard( + () => + new Promise((resolve, reject) => pending.push({ resolve, reject })), + ); + return pending; +} + const wait = (ms: number) => act(() => new Promise((resolve) => setTimeout(resolve, ms))); @@ -94,6 +104,52 @@ describe("useCopy", () => { hook.unmount(); }); + test("a stale write that settles last does not override a newer one", async () => { + const pending = holdClipboard(); + const hook = renderHook(() => useCopy({ timeout: 1000 })); + let first!: Promise; + let second!: Promise; + act(() => { + first = hook.result.current.copy("one"); + second = hook.result.current.copy("two"); + }); + await act(async () => { + pending[1]!.reject(new Error("denied")); + await second; + }); + expect(hook.result.current.copied).toBe(false); + let written: boolean | undefined; + await act(async () => { + pending[0]!.resolve(); + written = await first; + }); + expect(written).toBe(true); + expect(hook.result.current.copied).toBe(false); + hook.unmount(); + }); + + test("a stale failure that settles last does not clear a newer copy", async () => { + const pending = holdClipboard(); + const hook = renderHook(() => useCopy({ timeout: 1000 })); + let first!: Promise; + let second!: Promise; + act(() => { + first = hook.result.current.copy("one"); + second = hook.result.current.copy("two"); + }); + await act(async () => { + pending[1]!.resolve(); + await second; + }); + expect(hook.result.current.copied).toBe(true); + await act(async () => { + pending[0]!.reject(new Error("denied")); + await first; + }); + expect(hook.result.current.copied).toBe(true); + hook.unmount(); + }); + test("clears its timer on unmount", async () => { const cleared = mock(clearTimeout); const realClear = globalThis.clearTimeout; diff --git a/src/hooks/use-copy.ts b/src/hooks/use-copy.ts index 6ebeeacc..6bd298bb 100644 --- a/src/hooks/use-copy.ts +++ b/src/hooks/use-copy.ts @@ -16,7 +16,8 @@ export type UseCopyResult = { * Copy to the clipboard with a short-lived "copied" state for the control * that did it. A failed or unavailable clipboard leaves `copied` off, so a * control never claims a copy that did not happen. Copying again restarts the - * timer; unmounting clears it. + * timer; only the latest copy sets `copied`, even when an earlier write + * settles after it. Unmounting clears the timer. */ export function useCopy({ timeout = 2000, @@ -24,6 +25,7 @@ export function useCopy({ const [copied, setCopied] = useState(false); const timerRef = useRef | null>(null); const mountedRef = useRef(true); + const latestRef = useRef(0); useEffect(() => { mountedRef.current = true; @@ -36,13 +38,14 @@ export function useCopy({ const copy = useCallback( async (text: string) => { + const call = ++latestRef.current; let written = true; try { await navigator.clipboard.writeText(text); } catch { written = false; } - if (!mountedRef.current) return written; + if (!mountedRef.current || call !== latestRef.current) return written; if (timerRef.current !== null) clearTimeout(timerRef.current); timerRef.current = null; setCopied(written); diff --git a/src/ui/message-actions.tsx b/src/ui/message-actions.tsx index c9158ced..4b4510a4 100644 --- a/src/ui/message-actions.tsx +++ b/src/ui/message-actions.tsx @@ -169,7 +169,8 @@ function clipBounds(element: HTMLElement): { top: number; bottom: number } { * Opening it moves focus to the first item (ArrowUp on the trigger: the * last). Arrow keys, Home and End move between items; Escape closes it and * returns focus to the trigger, as do choosing an item, Shift+Tab and - * pressing the trigger again; focus leaving it or a click outside closes it. + * pressing the trigger again; focus leaving it or a click outside closes it + * without taking focus back. * It opens on `side` unless that side would clip it and the other has more * room. */ @@ -200,7 +201,11 @@ export function MessageActionMenu({ [ref, rootRef], ); const menuRef = useRef(null); - const focusOnOpen = useRef<"first" | "last">("first"); + // Which item the next open focuses. An ArrowUp on the trigger asks for the + // last; the request is dropped when the host does not open the menu at + // once, so a later open, by a click or by the host, starts on the first. + const [focusOnOpen, setFocusOnOpen] = useState<"first" | "last">("first"); + if (focusOnOpen === "last" && !open) setFocusOnOpen("first"); // Set by a press on the trigger while open. Safari, and Firefox on macOS, // do not focus a button on click, so the press blurs the menu with no // `relatedTarget`; that blur must leave closing to the trigger's click. @@ -233,15 +238,14 @@ export function MessageActionMenu({ useEffect(() => { if (!open) return; const items = menuItems(menuRef.current); - const target = focusOnOpen.current === "last" ? items.at(-1) : items[0]; + const target = focusOnOpen === "last" ? items.at(-1) : items[0]; (target ?? menuRef.current)?.focus(); - focusOnOpen.current = "first"; - }, [open]); + }, [open, focusOnOpen]); const onTriggerKeyDown = (event: KeyboardEvent) => { if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; event.preventDefault(); - focusOnOpen.current = event.key === "ArrowUp" ? "last" : "first"; + setFocusOnOpen(event.key === "ArrowUp" ? "last" : "first"); setOpen(true); }; @@ -296,8 +300,11 @@ export function MessageActionMenu({ }} onClick={() => { pressingTrigger.current = false; - if (open) close(); - else setOpen(true); + if (open) { + close(); + return; + } + setOpen(true); }} onKeyDown={onTriggerKeyDown} > diff --git a/src/ui/message.test.tsx b/src/ui/message.test.tsx index 46c9658d..c2e974f1 100644 --- a/src/ui/message.test.tsx +++ b/src/ui/message.test.tsx @@ -1,4 +1,4 @@ -import { act, createRef } from "react"; +import { act, createRef, useState } from "react"; import type { ReactElement } from "react"; import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; @@ -634,6 +634,81 @@ describe("MessageActionMenu", () => { mounted.unmount(); }); + test("a click opens on the first item after a host ignored an ArrowUp open", () => { + let allow = false; + function Host() { + const [open, setOpen] = useState(false); + return ( + { + if (next && !allow) return; + setOpen(next); + }} + > + + + + + ); + } + const mounted = render(); + const trigger = button(mounted.container, "More actions"); + trigger.focus(); + act(() => { + trigger.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowUp", + bubbles: true, + cancelable: true, + }), + ); + }); + expect(mounted.container.querySelector("[role='menu']")).toBeNull(); + allow = true; + act(() => trigger.click()); + expect(document.activeElement?.textContent).toBe("Pin"); + mounted.unmount(); + }); + + test("the host's own open after an ignored ArrowUp lands on the first item", () => { + let allow = false; + let openFromHost = () => {}; + function Host() { + const [open, setOpen] = useState(false); + openFromHost = () => setOpen(true); + return ( + { + if (next && !allow) return; + setOpen(next); + }} + > + + + + + ); + } + const mounted = render(); + const trigger = button(mounted.container, "More actions"); + trigger.focus(); + act(() => { + trigger.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowUp", + bubbles: true, + cancelable: true, + }), + ); + }); + expect(mounted.container.querySelector("[role='menu']")).toBeNull(); + act(() => openFromHost()); + expect(document.activeElement?.textContent).toBe("Pin"); + mounted.unmount(); + }); + test("a click outside closes it when the host passes a ref", () => { const ref = createRef(); const mounted = render( From 35d9b7fd5c23c773905e3ba1bf0b03b677da81d0 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 19:51:37 +0545 Subject: [PATCH 4/8] fix(message): mark a pressed action apart from hover without colour A pressed MessageAction in the row took `bg-muted` and `text-foreground`, the same two classes hover applies, and both are colour. Once the pointer left, the only difference between a set vote and an unset one was a faint tint and a darker icon, so a reader could not tell which vote was set. A pressed action now also draws an inset outline in the control-boundary colour, which clears 3:1 against the page and which hover does not draw. The MessageAction docs say what a pressed action looks like, and the e2e action row gains a pressed and an unpressed vote to check it. --- e2e/fixtures.tsx | 2 ++ e2e/message.spec.ts | 15 +++++++++++++++ src/ui/message-actions.tsx | 8 ++++++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index 36b3fdaa..1c989356 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -1153,6 +1153,8 @@ function ActionRow({ record }: { record: (event: string) => void }) { return ( record("copy")} /> + + record("pin")} /> record("link")} /> diff --git a/e2e/message.spec.ts b/e2e/message.spec.ts index 94567ec9..850bb649 100644 --- a/e2e/message.spec.ts +++ b/e2e/message.spec.ts @@ -104,6 +104,21 @@ const menu = (scope: Locator) => const item = (scope: Locator, name: string) => scope.getByRole("menuitem", { name }); +test("a pressed action differs from a hovered one by more than colour", async ({ + page, +}) => { + await page.goto("/message-actions"); + const top = row(page, "top"); + const pressed = top.getByRole("button", { name: "Good response" }); + const hovered = top.getByRole("button", { name: "Bad response" }); + await hovered.hover(); + const shadow = (button: Locator) => + button.evaluate((element) => getComputedStyle(element).boxShadow); + // The pressed action carries an inset outline that hover does not draw. + expect(await shadow(pressed)).toContain("inset"); + expect(await shadow(hovered)).not.toContain("inset"); +}); + test("the overflow menu works from the keyboard and returns focus", async ({ page, }) => { diff --git a/src/ui/message-actions.tsx b/src/ui/message-actions.tsx index 4b4510a4..9f7498dc 100644 --- a/src/ui/message-actions.tsx +++ b/src/ui/message-actions.tsx @@ -62,7 +62,9 @@ export type MessageActionProps = Omit< /** * One action: an icon button in `MessageActions`, or an item (icon and * label) inside a `MessageActionMenu`. Pass `aria-pressed` for an action that - * toggles, such as a vote. + * toggles, such as a vote: pressed, the button is filled and outlined (hover + * only fills it); in the menu the item is a checked `menuitemcheckbox` in + * bold. */ export function MessageAction({ label, @@ -102,6 +104,8 @@ export function MessageAction({ ); } return ( + // Pressed shares hover's fill, so an outline in the control-boundary + // colour marks it apart from hover by shape, not colour alone. ); } - return ( // Pressed shares hover's fill, so an outline in the control-boundary // colour marks it apart from hover by shape, not colour alone. + return (
); -} +}); From e03a471bfbd03e1aaeab8e4d5a84c1f9b7fa4e9d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 28 Oct 2026 13:42:20 -0700 Subject: [PATCH 6/8] fix(message): forward the ref on MessageAction under React 18 MessageAction read `ref` from its props. React 18 strips `ref` from the props of a plain function component, so the trigger ref MessageActionMenu passes never reached the button, and closing the overflow menu did not return focus to "More actions". The peer range still accepts React 18. MessageAction is now wrapped in forwardRef, which passes the ref on React 18 and 19 alike, and attaches it to the button it renders in the row and in the menu. --- src/ui/message-actions.tsx | 90 +++++++++++++++++++------------------- src/ui/message.test.tsx | 59 +++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 45 deletions(-) diff --git a/src/ui/message-actions.tsx b/src/ui/message-actions.tsx index 39f04e9f..a50da901 100644 --- a/src/ui/message-actions.tsx +++ b/src/ui/message-actions.tsx @@ -67,63 +67,63 @@ export type MessageActionProps = Omit< * only fills it); in the menu the item is a checked `menuitemcheckbox` in * bold. */ -export function MessageAction({ - label, - className, - children, - onClick, - "aria-pressed": pressed, - ...props -}: MessageActionProps) { - const menu = useContext(MenuContext); - if (menu !== null) { - // A toggle in a menu is a checkbox item: menus have no pressed state. - const toggle = pressed !== undefined; - const checked = pressed === true || pressed === "true"; +export const MessageAction = forwardRef( + function MessageAction( + { label, className, children, onClick, "aria-pressed": pressed, ...props }, + ref, + ) { + const menu = useContext(MenuContext); + if (menu !== null) { + // A toggle in a menu is a checkbox item: menus have no pressed state. + const toggle = pressed !== undefined; + const checked = pressed === true || pressed === "true"; + return ( + + ); + } + // Pressed shares hover's fill, so an outline in the control-boundary + // colour marks it apart from hover by shape, not colour alone. return ( ); - } - // Pressed shares hover's fill, so an outline in the control-boundary - // colour marks it apart from hover by shape, not colour alone. - return ( - - ); -} + }, +); export type MessageActionMenuProps = Omit, "children"> & { /** The trigger's accessible name and the menu's. Default "More actions". */ diff --git a/src/ui/message.test.tsx b/src/ui/message.test.tsx index c2e974f1..322a374d 100644 --- a/src/ui/message.test.tsx +++ b/src/ui/message.test.tsx @@ -594,6 +594,65 @@ describe("MessageActionMenu", () => { ); + test("forwards its ref argument, so Escape returns focus to the trigger", () => { + // React 18 drops `ref` from a function component's props: only a + // forwardRef component receives it as the render's second argument. + // A ref that merely lands on the button under React 19 does not prove that. + expect(MessageAction.$$typeof).toBe(Symbol.for("react.forward_ref")); + const action = MessageAction as unknown as { + render: (props: unknown, ref: unknown) => unknown; + }; + const original = action.render; + let received: unknown; + let propsHadRef = false; + action.render = (props, ref) => { + received = ref; + propsHadRef = + typeof props === "object" && + props !== null && + "ref" in props && + props.ref != null; + return original(props, ref); + }; + const refCurrent = (value: unknown): unknown => + typeof value === "object" && value !== null && "current" in value + ? value.current + : undefined; + try { + const rowRef = createRef(); + const row = render(); + expect(propsHadRef).toBe(false); + expect(received).toBe(rowRef); + expect(rowRef.current).toBe(button(row.container, "Copy")); + row.unmount(); + + const itemRef = createRef(); + const mounted = render( + + + , + ); + const trigger = button(mounted.container, "More actions"); + expect(propsHadRef).toBe(false); + expect(refCurrent(received)).toBe(trigger); + act(() => trigger.click()); + expect(received).toBe(itemRef); + expect(itemRef.current).toBe( + mounted.container.querySelector("[role='menuitem']"), + ); + act(() => { + document.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + }); + expect(mounted.container.querySelector("[role='menu']")).toBeNull(); + expect(document.activeElement).toBe(trigger); + mounted.unmount(); + } finally { + action.render = original; + } + }); + test("pressing the trigger closes an open menu when the click does not focus it", () => { const mounted = render(menu()); const trigger = button(mounted.container, "More actions"); From b3a6322dc0476ca4e2a9dfaa5e18176b7ddbda01 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 28 Oct 2026 13:44:03 -0700 Subject: [PATCH 7/8] test(message): keep the MessageAction ref check to a few lines Focus already returns to the trigger when an overflow item is chosen. Assert that MessageAction is a forwardRef there, instead of a separate test. --- src/ui/message.test.tsx | 62 ++--------------------------------------- 1 file changed, 3 insertions(+), 59 deletions(-) diff --git a/src/ui/message.test.tsx b/src/ui/message.test.tsx index 322a374d..8e0a2026 100644 --- a/src/ui/message.test.tsx +++ b/src/ui/message.test.tsx @@ -554,6 +554,9 @@ describe("MessageActions", () => { }); test("the overflow menu mounts its items only while open", () => { + // React 18 drops `ref` from a function component's props: only a + // forwardRef component receives it there. + expect(MessageAction.$$typeof).toBe(Symbol.for("react.forward_ref")); const onPin = mock(() => {}); const mounted = render( @@ -594,65 +597,6 @@ describe("MessageActionMenu", () => { ); - test("forwards its ref argument, so Escape returns focus to the trigger", () => { - // React 18 drops `ref` from a function component's props: only a - // forwardRef component receives it as the render's second argument. - // A ref that merely lands on the button under React 19 does not prove that. - expect(MessageAction.$$typeof).toBe(Symbol.for("react.forward_ref")); - const action = MessageAction as unknown as { - render: (props: unknown, ref: unknown) => unknown; - }; - const original = action.render; - let received: unknown; - let propsHadRef = false; - action.render = (props, ref) => { - received = ref; - propsHadRef = - typeof props === "object" && - props !== null && - "ref" in props && - props.ref != null; - return original(props, ref); - }; - const refCurrent = (value: unknown): unknown => - typeof value === "object" && value !== null && "current" in value - ? value.current - : undefined; - try { - const rowRef = createRef(); - const row = render(); - expect(propsHadRef).toBe(false); - expect(received).toBe(rowRef); - expect(rowRef.current).toBe(button(row.container, "Copy")); - row.unmount(); - - const itemRef = createRef(); - const mounted = render( - - - , - ); - const trigger = button(mounted.container, "More actions"); - expect(propsHadRef).toBe(false); - expect(refCurrent(received)).toBe(trigger); - act(() => trigger.click()); - expect(received).toBe(itemRef); - expect(itemRef.current).toBe( - mounted.container.querySelector("[role='menuitem']"), - ); - act(() => { - document.dispatchEvent( - new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), - ); - }); - expect(mounted.container.querySelector("[role='menu']")).toBeNull(); - expect(document.activeElement).toBe(trigger); - mounted.unmount(); - } finally { - action.render = original; - } - }); - test("pressing the trigger closes an open menu when the click does not focus it", () => { const mounted = render(menu()); const trigger = button(mounted.container, "More actions"); From 51539d74d1592cd88a750bc0f5a4227b5953ab5d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 28 Oct 2026 14:14:02 -0700 Subject: [PATCH 8/8] ci: retrigger checks against the updated stack The previous run merged a stack commit whose mid-glide test failed typecheck. That test is fixed on conversation/05-conversation.