From 86a86320d30d247d49331730950ee3513671f0ce Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 22 Oct 2026 00:50:43 +0530 Subject: [PATCH] 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 b63ef179..becd760f 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -51,6 +51,13 @@ import { groupActivity, type PartTool, type ToolApprovalRecord, + Message, + MessageAction, + MessageActionMenu, + MessageActions, + MessageContent, + MessageFold, + MessageFooter, } from "@corbits/react-ui"; import { CommandPalette, @@ -817,6 +824,121 @@ function ToolApprovalScenario() { ); } +/** 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": () => , @@ -855,4 +977,12 @@ export const scenarios: Record ReactNode> = { "prompt-input-toolbar": () => , "panel-tabs": () => , "tool-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 132007e5..8ff9fa6e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,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"; @@ -141,3 +142,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 ( +
+ + + + + + + + + + +
+ ); +}