diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index 88ceef0..1c98935 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,123 @@ 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 +1237,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 0000000..850bb64 --- /dev/null +++ b/e2e/message.spec.ts @@ -0,0 +1,196 @@ +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("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, +}) => { + 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 0000000..e46ce4e --- /dev/null +++ b/src/hooks/use-copy.test.ts @@ -0,0 +1,169 @@ +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 }, + }); +} + +/** 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))); + +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("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; + 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 0000000..6bd298b --- /dev/null +++ b/src/hooks/use-copy.ts @@ -0,0 +1,63 @@ +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; only the latest copy sets `copied`, even when an earlier write + * settles after it. Unmounting clears the timer. + */ +export function useCopy({ + timeout = 2000, +}: UseCopyOptions = {}): UseCopyResult { + const [copied, setCopied] = useState(false); + const timerRef = useRef | null>(null); + const mountedRef = useRef(true); + const latestRef = useRef(0); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + if (timerRef.current !== null) clearTimeout(timerRef.current); + timerRef.current = null; + }; + }, []); + + 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 || call !== latestRef.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 2402604..b3349ff 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 0000000..a50da90 --- /dev/null +++ b/src/ui/message-actions.tsx @@ -0,0 +1,346 @@ +"use client"; + +import { Ellipsis } from "lucide-react"; +import { + createContext, + forwardRef, + useCallback, + 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: pressed, the button is filled and outlined (hover + * only fills it); in the menu the item is a checked `menuitemcheckbox` in + * bold. + */ +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 ( + + ); + }, +); + +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 + * without taking focus back. + * It opens on `side` unless that side would clip it and the other has more + * room. + */ +export const MessageActionMenu = forwardRef< + HTMLDivElement, + MessageActionMenuProps +>(function MessageActionMenu( + { + label = "More actions", + icon, + side = "bottom", + align = "start", + open: openProp, + onOpenChange, + className, + children, + ...props + }, + ref, +) { + 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); + // 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. + 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 === "last" ? items.at(-1) : items[0]; + (target ?? menuRef.current)?.focus(); + }, [open, focusOnOpen]); + + const onTriggerKeyDown = (event: KeyboardEvent) => { + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + event.preventDefault(); + setFocusOnOpen(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(); + return; + } + 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 0000000..83cf931 --- /dev/null +++ b/src/ui/message-fold.test.tsx @@ -0,0 +1,327 @@ +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("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( + + 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 0000000..270e71d --- /dev/null +++ b/src/ui/message-fold.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { ChevronDown, ChevronUp } from "lucide-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"; +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; +} + +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. + * 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 bodyId = useId(); + const foldable = !streaming && length > threshold; + 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 0000000..ea15513 --- /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 0000000..726f4fd --- /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 0000000..8e0a202 --- /dev/null +++ b/src/ui/message.test.tsx @@ -0,0 +1,831 @@ +import { act, createRef, useState } 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", () => { + // 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( + + + + + + , + ); + 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(); + }); + + 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( + + + , + ); + 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", () => { + 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 0000000..e322013 --- /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 0000000..fadbeca --- /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 ( +
+ + + + + + + + + + +
+ ); +}