diff --git a/e2e/conversation.spec.ts b/e2e/conversation.spec.ts new file mode 100644 index 00000000..263ef542 --- /dev/null +++ b/e2e/conversation.spec.ts @@ -0,0 +1,401 @@ +import { expect, test, type Page } from "@playwright/test"; + +const container = (page: Page) => + page.locator("[data-slot='conversation-content']"); + +const view = (page: Page) => + container(page).evaluate((element) => ({ + scrollTop: element.scrollTop, + top: element.getBoundingClientRect().top, + distanceFromEnd: + element.scrollHeight - element.scrollTop - element.clientHeight, + })); + +/** How far an item's top sits below the top of the view. */ +const offsetOf = async (page: Page, id: string) => { + const { top } = await view(page); + const itemTop = await page + .getByTestId(id) + .evaluate((element) => element.getBoundingClientRect().top); + return itemTop - top; +}; + +/** Waits for the view to stop moving (a glide or a wheel scroll to land). */ +const settled = async (page: Page) => { + let previous = Number.NaN; + await expect + .poll( + async () => { + const { scrollTop } = await view(page); + const still = scrollTop === previous; + previous = scrollTop; + return still; + }, + { intervals: [100] }, + ) + .toBe(true); +}; + +/** The reader's own scroll: a wheel over the conversation. */ +const wheel = async (page: Page, deltaY: number) => { + const box = await container(page).boundingBox(); + if (box === null) throw new Error("expected the conversation on screen"); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.wheel(0, deltaY); + await settled(page); +}; + +const atEnd = async (page: Page) => { + await expect + .poll(async () => (await view(page)).distanceFromEnd) + .toBeLessThanOrEqual(1); +}; + +/** The id of the topmost item in view. */ +const firstVisible = (page: Page) => + container(page).evaluate((element) => { + const top = element.getBoundingClientRect().top; + const items = element.querySelectorAll("[data-testid]"); + for (const item of items) { + if (item.getBoundingClientRect().bottom > top) + return item.dataset.testid ?? ""; + } + return ""; + }); + +/** Opens a scenario once its history (which arrives after mount) is in. */ +const open = async (page: Page, scenario: string) => { + await page.goto(`/${scenario}`); + await expect(page.getByTestId("h29")).toBeAttached(); + await atEnd(page); +}; + +const write = async (page: Page, times: number) => { + for (let count = 0; count < times; count += 1) { + await page.getByRole("button", { name: "Write" }).click(); + } + // One frame for the resize callback, one for any correction to land. + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); +}; + +test.describe("settle", () => { + test("history that arrives after mount opens at the end", async ({ + page, + }) => { + await open(page, "conversation-settle"); + await expect(page.getByTestId("h29")).toBeVisible(); + }); + + test("a sent item settles at the top and streaming never moves the view", async ({ + page, + }) => { + await open(page, "conversation-settle"); + await page.getByRole("button", { name: "Send" }).click(); + await expect + .poll(async () => Math.abs(await offsetOf(page, "s0"))) + .toBeLessThanOrEqual(1); + await settled(page); + expect(Math.abs(await offsetOf(page, "s0"))).toBeLessThanOrEqual(1); + + // The reply writes in below, past the bottom of the view; nothing moves. + const before = (await view(page)).scrollTop; + await write(page, 8); + expect((await view(page)).scrollTop).toBe(before); + expect(Math.abs(await offsetOf(page, "s0"))).toBeLessThanOrEqual(1); + await expect( + page.getByRole("button", { name: "Scroll to latest" }), + ).toBeVisible(); + }); + + test("scrolling up while text streams leaves scrollTop unchanged", async ({ + page, + }) => { + await open(page, "conversation-settle"); + await page.getByRole("button", { name: "Send" }).click(); + await settled(page); + await wheel(page, -200); + const before = (await view(page)).scrollTop; + await write(page, 5); + expect((await view(page)).scrollTop).toBe(before); + }); + + test("loading earlier items keeps the item in view within 1px", async ({ + page, + }) => { + await open(page, "conversation-settle"); + await wheel(page, -300); + const reading = await firstVisible(page); + expect(reading).toMatch(/^h/); + const before = await offsetOf(page, reading); + await page.getByRole("button", { name: "Load earlier" }).click(); + await expect(page.getByTestId("p0-0")).toBeAttached(); + expect( + Math.abs((await offsetOf(page, reading)) - before), + ).toBeLessThanOrEqual(1); + }); + + test("trimming items above the view does not move it", async ({ page }) => { + await open(page, "conversation-settle"); + await wheel(page, -300); + const reading = await firstVisible(page); + expect(reading).toMatch(/^h([5-9]|\d\d)$/); + const before = await offsetOf(page, reading); + await page.getByRole("button", { name: "Trim" }).click(); + await expect(page.getByTestId("h0")).not.toBeAttached(); + await write(page, 0); + expect( + Math.abs((await offsetOf(page, reading)) - before), + ).toBeLessThanOrEqual(1); + }); + + test("removing the settled item does not move the view", async ({ page }) => { + await open(page, "conversation-settle"); + await page.getByRole("button", { name: "Send" }).click(); + await settled(page); + const before = await offsetOf(page, "a0"); + await page.getByRole("button", { name: "Delete sent" }).click(); + await expect(page.getByTestId("s0")).not.toBeAttached(); + await write(page, 0); + expect(Math.abs((await offsetOf(page, "a0")) - before)).toBeLessThanOrEqual( + 1, + ); + }); + + test("a remounted log keeps the reader's place and the reserve", async ({ + page, + }) => { + await open(page, "conversation-settle"); + await page.getByRole("button", { name: "Send" }).click(); + await settled(page); + const reserve = () => + page + .locator("[data-slot='conversation-reserve']") + .evaluate((element) => element.getBoundingClientRect().height); + const held = await reserve(); + expect(held).toBeGreaterThan(0); + // Up from the settled item, far enough that the end leaves the view. + await wheel(page, -600); + const reading = await firstVisible(page); + const before = await offsetOf(page, reading); + + await page.getByRole("button", { name: "Remount" }).click(); + await write(page, 0); + expect(await firstVisible(page)).toBe(reading); + expect( + Math.abs((await offsetOf(page, reading)) - before), + ).toBeLessThanOrEqual(1); + expect(Math.abs((await reserve()) - held)).toBeLessThanOrEqual(1); + + await page.getByRole("button", { name: "Scroll to latest" }).click(); + await settled(page); + // The content ends at the bottom of the view, not a reserve short of it. + const below = await container(page).evaluate((element) => { + const spacer = element.querySelector( + "[data-slot='conversation-reserve']", + ); + return ( + (spacer?.getBoundingClientRect().top ?? Number.NaN) - + element.getBoundingClientRect().bottom + ); + }); + expect(Math.abs(below)).toBeLessThanOrEqual(1); + }); + + test("under reduced motion the settle is an instant jump", async ({ + page, + }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await open(page, "conversation-settle"); + await page.getByRole("button", { name: "Send" }).click(); + // No glide: in place by the time the click's own frame has painted. + const offset = await page.evaluate(() => { + const item = document.querySelector("[data-testid='s0']"); + const scroller = document.querySelector( + "[data-slot='conversation-content']", + ); + if (item === null || scroller === null) return Number.NaN; + return ( + item.getBoundingClientRect().top - scroller.getBoundingClientRect().top + ); + }); + expect(Math.abs(offset)).toBeLessThanOrEqual(1); + }); +}); + +test.describe("stick", () => { + test("at the end, streaming text keeps the end in view", async ({ page }) => { + await open(page, "conversation-stick"); + await expect(page.getByTestId("h29")).toBeVisible(); + await write(page, 6); + await atEnd(page); + await expect( + page.getByRole("button", { name: "Scroll to latest" }), + ).toHaveCount(0); + }); + + test("scrolled up, streaming does not move the view", async ({ page }) => { + await open(page, "conversation-stick"); + await wheel(page, -300); + const before = (await view(page)).scrollTop; + await write(page, 6); + expect((await view(page)).scrollTop).toBe(before); + + const button = page.getByRole("button", { name: "Scroll to latest" }); + await button.click(); + await atEnd(page); + await expect(button).toHaveCount(0); + await expect(container(page)).toBeFocused(); + + // Back at the end, it follows again. + await write(page, 3); + await atEnd(page); + }); + + test("sending glides back to the end from anywhere", async ({ page }) => { + await open(page, "conversation-stick"); + await wheel(page, -400); + await page.getByRole("button", { name: "Send" }).click(); + await atEnd(page); + }); +}); + +for (const policy of ["settle", "stick"]) { + test(`${policy}: focus moving to an earlier item keeps the view there`, async ({ + page, + }) => { + await open(page, `conversation-${policy}`); + // Once the history has laid out and the view reached the end. + await settled(page); + // Focus moved by the host or assistive tech: no input on the log. + await page.getByTestId("h3").evaluate((element) => { + element.tabIndex = -1; + element.focus(); + }); + await settled(page); + const offset = await offsetOf(page, "h3"); + expect(offset).toBeGreaterThanOrEqual(-1); + expect(offset).toBeLessThan(400); + const before = (await view(page)).scrollTop; + await expect( + page.getByRole("button", { name: "Scroll to latest" }), + ).toBeVisible(); + + // The follow is broken: streaming does not pull the view to the end. + await write(page, 3); + expect((await view(page)).scrollTop).toBe(before); + }); +} + +type GlideFrame = { readonly t: number; readonly offset: number }; + +/** Waits for the fixture's frame log of the glide (see recordGlide). */ +const glideLog = async (page: Page) => { + await expect + .poll(() => + page.evaluate( + () => (window as { glideLog?: { done: boolean } }).glideLog?.done, + ), + ) + .toBe(true); + return page.evaluate( + () => + ( + window as unknown as { + glideLog: { frames: GlideFrame[]; changedAt: number }; + } + ).glideLog, + ); +}; + +/** + * The tracked item moves one way only, and never faster than the frame + * before: an ease-out only slows down, so a hop shows as a sudden speed-up. + * `direction` is the sign of its offset's change while the view glides. + */ +const expectSmooth = (frames: readonly GlideFrame[], direction: 1 | -1) => { + const step = (index: number) => + ((frames[index]?.offset ?? 0) - (frames[index - 1]?.offset ?? 0)) * + direction; + const speed = (index: number) => + step(index) / ((frames[index]?.t ?? 0) - (frames[index - 1]?.t ?? 0) || 1); + for (let index = 1; index < frames.length; index += 1) { + expect( + step(index), + `frame ${index} moved backwards`, + ).toBeGreaterThanOrEqual(-1); + // The first interval can straddle the click, so speeds compare from the + // third frame; 0.5px/ms covers whole-pixel rounding. + if (index >= 3) { + expect(speed(index), `frame ${index} sped up: a hop`).toBeLessThanOrEqual( + speed(index - 1) + 0.5, + ); + } + } +}; + +/** The content changed while the view was still gliding. */ +const expectChangedMidGlide = ( + frames: readonly GlideFrame[], + changedAt: number, +) => { + const moving = frames.filter( + (frame, index) => + index > 0 && + Math.abs(frame.offset - (frames[index - 1]?.offset ?? 0)) > 1, + ); + expect(moving.some((frame) => frame.t > changedAt)).toBe(true); +}; + +test.describe("a glide over content that moves above it", () => { + test("history landing above mid-glide still lands the item at the top", async ({ + page, + }) => { + await page.goto("/conversation-glide"); + await atEnd(page); + await page.getByRole("button", { name: "Glide up and load" }).click(); + const { frames, changedAt } = await glideLog(page); + await expect(page.getByTestId("p0")).toBeAttached(); + expectChangedMidGlide(frames, changedAt); + // h3 comes down from above the view to its top, never past it. + expectSmooth(frames, 1); + for (const frame of frames) expect(frame.offset).toBeLessThanOrEqual(1); + expect(Math.abs(frames.at(-1)?.offset ?? Number.NaN)).toBeLessThanOrEqual( + 1, + ); + expect(Math.abs(await offsetOf(page, "h3"))).toBeLessThanOrEqual(1); + }); + + test("trimming above while gliding to the end shows no hop", async ({ + page, + }) => { + await page.goto("/conversation-glide"); + await atEnd(page); + await wheel(page, -300); + await page.getByRole("button", { name: "Glide down and trim" }).click(); + const { frames, changedAt } = await glideLog(page); + await expect(page.getByTestId("h11")).not.toBeAttached(); + expectChangedMidGlide(frames, changedAt); + // The item in view rises steadily as the view glides down to the end. + expectSmooth(frames, -1); + await atEnd(page); + }); +}); + +test("the empty state centres in the log", async ({ page }) => { + await page.goto("/conversation-empty"); + const empty = page.locator("[data-slot='empty-state']"); + await expect(empty).toBeVisible(); + const log = await container(page).boundingBox(); + const box = await empty.boundingBox(); + if (log === null || box === null) throw new Error("expected both on screen"); + expect( + Math.abs(box.y + box.height / 2 - (log.y + log.height / 2)), + ).toBeLessThanOrEqual(1); + // Centring fills the view, never overflows it. + expect((await view(page)).distanceFromEnd).toBe(0); +}); diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index c343ae2f..efdddd17 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useLayoutEffect, useState } from "react"; import type { ReactNode } from "react"; import { @@ -6,6 +6,11 @@ import { AuthLayout, Button, ChatThread, + Conversation, + ConversationContent, + ConversationEmptyState, + ConversationItem, + ConversationScrollButton, DictationStatusLine, FilterChip, Select, @@ -15,6 +20,8 @@ import { ThinkingMark, type ApprovalRequest, type ChatMessage, + type ConversationScrollPolicy, + useConversation, } from "@corbits/react-ui"; import { CommandPalette, @@ -172,6 +179,286 @@ function Swatches() { ); } +type ScenarioRow = { + readonly id: string; + readonly from: "reader" | "agent"; + /** Height in 24px lines, so positions are exact on every platform. */ + readonly lines: number; +}; + +function historyRows(prefix: string, count: number): ScenarioRow[] { + return Array.from({ length: count }, (_, index) => ({ + id: `${prefix}${index}`, + from: index % 2 === 0 ? "reader" : "agent", + lines: 1 + (index % 3), + })); +} + +function ConversationControls({ + onSend, + onWrite, + onEarlier, + onTrim, + onUnsend, + onRemount, +}: { + onSend: () => void; + onWrite: () => void; + onEarlier: () => void; + onTrim: () => void; + onUnsend: () => void; + onRemount: () => void; +}) { + const { captureAnchor, restoreAnchor } = useConversation(); + const [loads, setLoads] = useState(0); + useLayoutEffect(() => { + if (loads > 0) restoreAnchor(); + }, [loads, restoreAnchor]); + return ( +
+ + + + + + +
+ ); +} + +/** History arrives after mount, as it does from a server. */ +function ConversationScenario({ + policy, +}: { + policy: ConversationScrollPolicy; +}) { + const [rows, setRows] = useState([]); + const [sentId, setSentId] = useState(); + const [sends, setSends] = useState(0); + const [pages, setPages] = useState(0); + const [mounts, setMounts] = useState(0); + useEffect(() => { + const timer = setTimeout(() => setRows(historyRows("h", 30)), 50); + return () => clearTimeout(timer); + }, []); + return ( +
+ + + {rows.map((row) => ( + +

+ {row.from === "reader" ? "Reader" : "Agent"} {row.id} +

+
+ ))} +
+ + { + const id = `s${sends}`; + setSends(sends + 1); + setRows((previous) => [ + ...previous, + { id, from: "reader", lines: 1 }, + { id: `a${sends}`, from: "agent", lines: 1 }, + ]); + setSentId(id); + }} + onWrite={() => + setRows((previous) => + previous.map((row, index) => + index === previous.length - 1 + ? { ...row, lines: row.lines + 3 } + : row, + ), + ) + } + onEarlier={() => { + setPages(pages + 1); + setRows((previous) => [ + ...historyRows(`p${pages}-`, 10), + ...previous, + ]); + }} + onTrim={() => setRows((previous) => previous.slice(5))} + onUnsend={() => + setRows((previous) => previous.filter((row) => row.id !== sentId)) + } + onRemount={() => setMounts(mounts + 1)} + /> +
+
+ ); +} + +/** One frame of a recorded glide: where the tracked item's top sits below + * the top of the view, after the glide has written that frame. */ +type GlideFrame = { readonly t: number; readonly offset: number }; + +type GlideLog = { + readonly frames: GlideFrame[]; + /** When the content above changed, on the frames' clock. */ + changedAt: number | null; + done: boolean; +}; + +/** Records `item`'s offset every frame until the view has been still for ten + * frames. Started after the glide, so each frame runs after the glide's. */ +function recordGlide(scroller: HTMLElement, item: HTMLElement): GlideLog { + const log: GlideLog = { frames: [], changedAt: null, done: false }; + let still = 0; + let last = Number.NaN; + const tick = (t: number) => { + log.frames.push({ + t, + offset: + item.getBoundingClientRect().top - scroller.getBoundingClientRect().top, + }); + still = scroller.scrollTop === last ? still + 1 : 0; + last = scroller.scrollTop; + if (still < 10 && log.frames.length < 300) requestAnimationFrame(tick); + else log.done = true; + }; + requestAnimationFrame(tick); + Object.assign(window, { glideLog: log }); + return log; +} + +function GlideControls({ + onLoad, + onTrim, +}: { + onLoad: () => void; + onTrim: () => void; +}) { + const { + items, + scrollContainer, + scrollToItem, + scrollToEnd, + captureAnchor, + restoreAnchor, + } = useConversation(); + const [loads, setLoads] = useState(0); + useLayoutEffect(() => { + if (loads > 0) restoreAnchor(); + }, [loads, restoreAnchor]); + /** Glides, then changes the content above while the glide is in flight. */ + const glideThen = (glide: () => void, id: string, change: () => void) => { + const scroller = scrollContainer(); + const item = items.get(id); + if (scroller === null || item === undefined) return; + glide(); + const log = recordGlide(scroller, item); + setTimeout(() => { + log.changedAt = performance.now(); + change(); + }, 120); + }; + const topmostInView = () => { + const top = scrollContainer()?.getBoundingClientRect().top ?? 0; + return ( + items + .ids() + .find( + (id) => + (items.get(id)?.getBoundingClientRect().bottom ?? 0) > top + 1, + ) ?? "" + ); + }; + return ( +
+ + +
+ ); +} + +/** Content changing above the view while a glide is in flight. */ +function ConversationGlideScenario() { + const [rows, setRows] = useState(() => historyRows("h", 30)); + return ( +
+ + + {rows.map((row) => ( + +

+ {row.from === "reader" ? "Reader" : "Agent"} {row.id} +

+
+ ))} +
+ + setRows((previous) => [...historyRows("p", 10), ...previous]) + } + onTrim={() => setRows((previous) => previous.slice(12))} + /> +
+
+ ); +} + +/** A conversation with nothing in it yet. */ +function ConversationEmptyScenario() { + return ( +
+ + + + + +
+ ); +} + export const scenarios: Record ReactNode> = { smoke: () => , "approval-card": () => , @@ -201,4 +488,8 @@ export const scenarios: Record ReactNode> = { ), + "conversation-settle": () => , + "conversation-stick": () => , + "conversation-glide": () => , + "conversation-empty": () => , }; diff --git a/src/index.ts b/src/index.ts index 09beeda9..3accd3f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -126,3 +126,5 @@ export * from "./ui/trace-waterfall.js"; export * from "./ui/typing-indicator.js"; export * from "./ui/view-toggle.js"; export * from "./ui/voice-waveform.js"; +export * from "./ui/conversation.js"; +export * from "./ui/conversation-divider.js"; diff --git a/src/lib/conversation-scroll.test.ts b/src/lib/conversation-scroll.test.ts new file mode 100644 index 00000000..0dde98cb --- /dev/null +++ b/src/lib/conversation-scroll.test.ts @@ -0,0 +1,502 @@ +import { describe, expect, test } from "bun:test"; + +import { + anchorCorrection, + anchorShift, + captureAnchor, + clampScrollTop, + classifyScroll, + distanceFromEnd, + extendGesture, + glideDuration, + glidePosition, + rebaseGlide, + releasedReserve, + reservedSpace, + settleTarget, + type ConversationSpan, +} from "./conversation-scroll.js"; + +/** Items 100px tall stacked from `start`, in client coordinates. */ +function stack(ids: readonly string[], start = 0, height = 100) { + return new Map( + ids.map((id, index) => [ + id, + { top: start + index * height, bottom: start + (index + 1) * height }, + ]), + ); +} + +const VIEW = { top: 0, bottom: 300 }; + +describe("settleTarget", () => { + test("puts the item's top at the container's top", () => { + const target = settleTarget({ + scrollTop: 400, + viewportTop: 50, + itemTop: 230, + }); + // The item sits 180px below the view's top; scrolling 180px further brings it there. + expect(target).toBe(580); + const itemTopAfter = 230 - (target - 400); + expect(itemTopAfter).toBe(50); + }); + + test("leaves an inset clear above the item", () => { + expect( + settleTarget({ scrollTop: 0, viewportTop: 0, itemTop: 200, inset: 40 }), + ).toBe(160); + }); +}); + +describe("reservedSpace", () => { + test("is the viewport minus the item when nothing follows it", () => { + expect( + reservedSpace({ viewportHeight: 600, itemTop: 1000, contentEnd: 1080 }), + ).toBe(520); + }); + + test("shrinks by as much as the reply grows", () => { + const before = reservedSpace({ + viewportHeight: 600, + itemTop: 1000, + contentEnd: 1080, + }); + const after = reservedSpace({ + viewportHeight: 600, + itemTop: 1000, + contentEnd: 1280, + }); + expect(before - after).toBe(200); + }); + + test("is never negative", () => { + expect( + reservedSpace({ viewportHeight: 600, itemTop: 1000, contentEnd: 2000 }), + ).toBe(0); + expect( + reservedSpace({ viewportHeight: 600, itemTop: 1000, contentEnd: 1700 }), + ).toBe(0); + }); +}); + +describe("releasedReserve", () => { + test("keeps the part of the view that hangs below the content", () => { + // The view's bottom sits 240px past the content's end, inside the reserve. + expect(releasedReserve({ scrollTop: 1000, distanceFromEnd: -240 })).toBe( + 240, + ); + }); + + test("is nothing once the content reaches the view's bottom", () => { + expect(releasedReserve({ scrollTop: 1000, distanceFromEnd: 0 })).toBe(0); + expect(releasedReserve({ scrollTop: 1000, distanceFromEnd: 500 })).toBe(0); + }); + + test("is nothing at the top, where no clamp can move the view", () => { + // Content shorter than the view: nothing needs holding. + expect(releasedReserve({ scrollTop: 0, distanceFromEnd: -300 })).toBe(0); + }); +}); + +describe("captureAnchor", () => { + test("records the items in view, topmost first, with their offsets", () => { + const boxes = stack(["a", "b", "c", "d", "e", "f"], -150); + const anchor = captureAnchor(VIEW, [...boxes.keys()], (id) => + boxes.get(id), + ); + expect(anchor.candidates).toEqual([ + { id: "b", offset: -50 }, + { id: "c", offset: 50 }, + { id: "d", offset: 150 }, + { id: "e", offset: 250 }, + ]); + }); + + test("finds the first visible item in a long history without measuring it all", () => { + const ids = Array.from({ length: 1000 }, (_, index) => `m${index}`); + const boxes = stack(ids, -50_000); + const measured: string[] = []; + const anchor = captureAnchor(VIEW, ids, (id) => { + measured.push(id); + return boxes.get(id); + }); + expect(anchor.candidates[0]).toEqual({ id: "m500", offset: 0 }); + expect(measured.length).toBeLessThan(20); + }); + + test("captures nothing from an empty conversation", () => { + expect(captureAnchor(VIEW, [], () => undefined).candidates).toEqual([]); + }); +}); + +describe("anchorCorrection", () => { + const boxes = stack(["a", "b", "c", "d"], -50); + const anchor = captureAnchor(VIEW, [...boxes.keys()], (id) => boxes.get(id)); + + test("content inserted above restores the same offset", () => { + // 240px of older items land above: every top moves down by 240. + const correction = anchorCorrection( + anchor, + VIEW.top, + (id) => (boxes.get(id)?.top ?? 0) + 240, + ); + expect(correction).toBe(240); + const restored = (boxes.get("a")?.top ?? 0) + 240 - correction; + expect(restored - VIEW.top).toBe(-50); + }); + + test("nothing changed above means nothing moves", () => { + expect(anchorCorrection(anchor, VIEW.top, (id) => boxes.get(id)?.top)).toBe( + 0, + ); + }); + + test("when the anchor item is gone, the next candidate restores its own offset", () => { + // `a` is trimmed and the rest shift up by 100. + const topOf = (id: string) => + id === "a" ? undefined : (boxes.get(id)?.top ?? 0) - 100; + const correction = anchorCorrection(anchor, VIEW.top, topOf); + expect(correction).toBe(-100); + expect((topOf("b") ?? 0) - correction).toBe(boxes.get("b")?.top ?? NaN); + }); + + test("with no candidate left, nothing moves", () => { + expect(anchorCorrection(anchor, VIEW.top, () => undefined)).toBe(0); + expect(anchorCorrection({ candidates: [] }, VIEW.top, () => 0)).toBe(0); + }); +}); + +describe("distanceFromEnd", () => { + test("measures to the end of the content, not of the reserved space", () => { + expect( + distanceFromEnd({ + scrollHeight: 2000, + scrollTop: 1000, + clientHeight: 600, + }), + ).toBe(400); + expect( + distanceFromEnd({ + scrollHeight: 2000, + scrollTop: 1400, + clientHeight: 600, + reserved: 300, + }), + ).toBe(-300); + }); +}); + +describe("clampScrollTop", () => { + test("keeps a target within what the container can reach", () => { + expect(clampScrollTop(-20, 2000, 600)).toBe(0); + expect(clampScrollTop(1800, 2000, 600)).toBe(1400); + expect(clampScrollTop(700, 2000, 600)).toBe(700); + expect(clampScrollTop(50, 400, 600)).toBe(0); + }); +}); + +describe("classifyScroll", () => { + test("a scroll inside an open gesture is the reader's", () => { + expect( + classifyScroll({ + scrollTop: 10, + written: null, + now: 100, + gestureUntil: 150, + }), + ).toBe("reader"); + expect( + classifyScroll({ + scrollTop: 10, + written: 400, + now: 100, + gestureUntil: 150, + }), + ).toBe("reader"); + }); + + test("the container's own write is its own, even inside a gesture", () => { + expect( + classifyScroll({ + scrollTop: 10, + written: 10, + now: 100, + gestureUntil: 150, + }), + ).toBe("self"); + expect( + classifyScroll({ + scrollTop: 10, + written: 10, + now: 100, + gestureUntil: Number.POSITIVE_INFINITY, + }), + ).toBe("self"); + }); + + test("landing where the container last wrote is its own", () => { + expect( + classifyScroll({ + scrollTop: 400.4, + written: 400, + now: 200, + gestureUntil: 150, + }), + ).toBe("self"); + }); + + test("anything else is the layout's", () => { + expect( + classifyScroll({ + scrollTop: 380, + written: 400, + now: 200, + gestureUntil: 150, + }), + ).toBe("layout"); + expect( + classifyScroll({ + scrollTop: 380, + written: null, + now: 200, + gestureUntil: 0, + }), + ).toBe("layout"); + }); +}); + +describe("extendGesture", () => { + test("outside a momentum phase, a scroll never extends the gesture", () => { + expect( + extendGesture({ now: 100, gestureUntil: 150, momentumUntil: 0 }), + ).toBe(150); + }); + + test("in momentum, each scroll keeps it open another quiet period", () => { + expect( + extendGesture({ now: 100, gestureUntil: 150, momentumUntil: 2_000 }), + ).toBe(250); + // Never shortens a window that is already open longer. + expect( + extendGesture({ now: 100, gestureUntil: 400, momentumUntil: 2_000 }), + ).toBe(400); + }); + + test("never past the momentum cap", () => { + expect( + extendGesture({ now: 1_950, gestureUntil: 2_000, momentumUntil: 2_000 }), + ).toBe(2_000); + expect( + extendGesture({ now: 2_050, gestureUntil: 2_100, momentumUntil: 2_000 }), + ).toBe(2_100); + }); +}); + +describe("glide", () => { + test("takes longer for longer jumps, within bounds", () => { + expect(glideDuration(0)).toBe(320); + expect(glideDuration(1000)).toBe(400); + expect(glideDuration(-1000)).toBe(400); + expect(glideDuration(100_000)).toBe(720); + }); + + test("starts at the start and lands exactly on a moving target", () => { + expect(glidePosition(100, 900, 0)).toBe(100); + expect(glidePosition(100, 900, 1)).toBe(900); + expect(glidePosition(100, 1200, 1)).toBe(1200); + const half = glidePosition(0, 1000, 0.5); + expect(half).toBeGreaterThan(500); + expect(half).toBeLessThan(1000); + }); +}); + +describe("anchorShift", () => { + const boxes = stack(["a", "b", "c", "d"], -50); + const anchor = captureAnchor(VIEW, [...boxes.keys()], (id) => boxes.get(id)); + + test("nothing moved means no shift", () => { + expect( + anchorShift( + anchor, + { viewportTop: VIEW.top, scrollTop: 500, capturedTop: 500 }, + (id) => boxes.get(id)?.top, + ), + ).toBe(0); + }); + + test("counts a clamp the browser applied as well as the correction", () => { + // 300px trimmed above; the browser clamped scrollTop from 500 to 400, so + // on screen the items only rose by 200. + const shift = anchorShift( + anchor, + { viewportTop: VIEW.top, scrollTop: 400, capturedTop: 500 }, + (id) => (boxes.get(id)?.top ?? 0) - 200, + ); + expect(shift).toBe(-300); + }); + + test("with no candidate left, nothing moves even if scrollTop did", () => { + expect( + anchorShift( + anchor, + { viewportTop: VIEW.top, scrollTop: 400, capturedTop: 500 }, + () => undefined, + ), + ).toBe(0); + }); +}); + +/** + * Runs a glide frame by frame over 100px items, the way the engine does: + * re-base on any shift since the last frame, ease towards the re-resolved + * target, re-capture. `change` runs before frame `at` and edits the content. + * The view's top is at client 0, so a client top is a scroll top minus + * `scrollTop`. + */ +function simulateGlide({ + count, + viewHeight, + start, + target, + change, +}: { + readonly count: number; + readonly viewHeight: number; + readonly start: number; + readonly target: (content: Content) => number; + readonly change?: { + readonly at: number; + readonly apply: (content: Content) => void; + }; +}) { + const content: Content = { + ids: Array.from({ length: count }, (_, index) => `m${index}`), + offset: 0, + }; + const topOf = (id: string) => + content.ids.includes(id) + ? Number(id.slice(1)) * 100 + content.offset + : undefined; + const height = () => content.ids.length * 100; + const view = { top: 0, bottom: viewHeight }; + let from = start; + let scrollTop = start; + const capture = () => ({ + anchor: captureAnchor(view, content.ids, (id) => { + const top = topOf(id); + return top === undefined + ? undefined + : { top: top - scrollTop, bottom: top + 100 - scrollTop }; + }), + capturedTop: scrollTop, + }); + let captured = capture(); + const positions: number[] = []; + const offsets: number[] = []; + for (let frame = 0; frame <= 10; frame += 1) { + if (change !== undefined && frame === change.at) { + change.apply(content); + // The browser keeps scrollTop, clamped to what it can reach. + scrollTop = clampScrollTop(scrollTop, height(), viewHeight); + } + const shift = anchorShift( + captured.anchor, + { viewportTop: 0, scrollTop, capturedTop: captured.capturedTop }, + (id) => { + const top = topOf(id); + return top === undefined ? undefined : top - scrollTop; + }, + ); + if (Math.abs(shift) > 0.5) { + const next = rebaseGlide({ + start: from, + capturedTop: captured.capturedTop, + shift, + }); + from = next.start; + scrollTop = next.scrollTop; + } + scrollTop = glidePosition(from, target(content), frame / 10); + captured = capture(); + positions.push(scrollTop); + offsets.push((topOf("m24") ?? Number.NaN) - scrollTop); + } + return { positions, offsets, height: height() }; +} + +type Content = { ids: string[]; offset: number }; + +describe("a glide over content that moves above it", () => { + test("a page landing above mid-glide lands on the re-resolved target", () => { + const toItem = (content: Content) => 200 + content.offset; + const plain = simulateGlide({ + count: 30, + viewHeight: 400, + start: 2000, + target: toItem, + }); + const loaded = simulateGlide({ + count: 30, + viewHeight: 400, + start: 2000, + target: toItem, + change: { + at: 5, + apply: (content) => { + content.offset += 640; + }, + }, + }); + expect(Math.abs((loaded.positions.at(-1) ?? 0) - 840)).toBeLessThanOrEqual( + 1, + ); + // The same path through the content, 640px further down: nothing jumps. + loaded.positions.forEach((position, frame) => { + const expected = (plain.positions[frame] ?? 0) + (frame >= 5 ? 640 : 0); + expect(Math.abs(position - expected)).toBeLessThanOrEqual(1); + }); + }); + + test("trimming above while gliding to the end only moves the view by the trim", () => { + const toEnd = (content: Content) => + clampScrollTop( + content.ids.length * 100 - 400, + content.ids.length * 100, + 400, + ); + const plain = simulateGlide({ + count: 30, + viewHeight: 400, + start: 2000, + target: toEnd, + }); + const trimmed = simulateGlide({ + count: 30, + viewHeight: 400, + start: 2000, + target: toEnd, + change: { + // At frame 5 the view is past the trimmed end, so the browser clamps. + at: 5, + apply: (content) => { + content.ids.splice(0, 7); + content.offset -= 700; + }, + }, + }); + expect(trimmed.height).toBe(2300); + expect( + Math.abs((trimmed.positions.at(-1) ?? 0) - 1900), + ).toBeLessThanOrEqual(1); + // An item in view sits exactly where it would have without the trim, on + // every frame: the reader sees no hop and no backwards step. + trimmed.offsets.forEach((offset, frame) => { + expect( + Math.abs(offset - (plain.offsets[frame] ?? 0)), + ).toBeLessThanOrEqual(1); + if (frame > 0) + expect(offset).toBeLessThanOrEqual(trimmed.offsets[frame - 1] ?? 0); + }); + }); +}); diff --git a/src/lib/conversation-scroll.ts b/src/lib/conversation-scroll.ts new file mode 100644 index 00000000..17154274 --- /dev/null +++ b/src/lib/conversation-scroll.ts @@ -0,0 +1,328 @@ +/** + * The geometry behind `Conversation`'s scrolling, as pure functions over + * measured rectangles. The component measures; these decide. + * + * The anchor algorithm + * + * The container turns the browser's own scroll anchoring off + * (`overflow-anchor: none`) so it cannot fight this one, which replaces it: + * + * 1. Capture: record every registered item that intersects the view, topmost + * first, each with its offset from the container's top. The topmost is the + * anchor; the rest are candidates, in order. + * 2. Restore: after content changes above the anchor, and on each + * `ResizeObserver` callback until the anchor is released, scroll the + * container by the difference between the anchor's offset now and its + * captured offset, so what the reader sees stays put. + * 3. Hand-over: when the anchor item is gone (trimmed out of a history + * window), the next captured candidate below it takes over and restores + * its own offset. With no candidate left, nothing moves. + * + * Capture happens whenever the reader (or the container itself) scrolls, and + * on demand before a change the host is about to make. It is never taken + * from a scroll the layout caused: that position already includes the shift + * the anchor exists to undo. The anchor is released while `stick` follows the + * end (the end is the anchor then). + * + * During a glide + * + * A glide re-captures the anchor after every frame it writes. A change above + * the view mid-glide (a page of history landing, items trimmed) is measured + * the same way, as how far the anchored content moved in scroll coordinates + * (`anchorShift`), and is applied to the glide instead of fighting it + * (`rebaseGlide`): the view moves with the content, the glide's start moves + * by as much, and its target is re-resolved every frame. The glide then keeps + * the same path through the content and lands where it would have. + */ + +/** A vertical extent in one coordinate space (client pixels, usually). */ +export type ConversationSpan = { + readonly top: number; + readonly bottom: number; +}; + +/** One item's place relative to the top of the view when it was captured. */ +export type ConversationAnchorCandidate = { + readonly id: string; + readonly offset: number; +}; + +/** The items in view at capture time, topmost first. */ +export type ConversationAnchor = { + readonly candidates: readonly ConversationAnchorCandidate[]; +}; + +/** Within this distance of the end counts as at the end. */ +export const CONVERSATION_END_THRESHOLD = 32; + +/** A reader's gesture stays open this long after its last input (a wheel + * tick, a touch, a scroll key, the scrollbar released), so the scroll that + * input animates is still theirs. Outside a momentum phase, scrolls never + * extend it. */ +export const CONVERSATION_GESTURE_QUIET_MS = 150; + +/** How long after a touch lifts, or a wheel tick, the browser's own momentum + * (or smooth-scroll animation) can keep extending the reader's gesture. A + * hard cap, so no chain of scrolls keeps the gesture open for ever. */ +export const CONVERSATION_MOMENTUM_MS = 2000; + +/** + * Records the items intersecting `viewport`, topmost first, each with its + * offset from the viewport's top. + * + * `ids` must be in document order, which in a conversation column is also + * top-to-bottom order, so the first visible item is found by binary search: + * capture runs on every scroll and measures a handful of items, not the whole + * history. + */ +export function captureAnchor( + viewport: ConversationSpan, + ids: readonly string[], + measure: (id: string) => ConversationSpan | undefined, +): ConversationAnchor { + let low = 0; + let high = ids.length; + while (low < high) { + const middle = (low + high) >> 1; + const id = ids[middle]; + const box = id === undefined ? undefined : measure(id); + if (box !== undefined && box.bottom <= viewport.top) low = middle + 1; + else high = middle; + } + const candidates: ConversationAnchorCandidate[] = []; + for (let index = low; index < ids.length; index += 1) { + const id = ids[index]; + const box = id === undefined ? undefined : measure(id); + if (id === undefined || box === undefined) continue; + if (box.top >= viewport.bottom) break; + candidates.push({ id, offset: box.top - viewport.top }); + } + return { candidates }; +} + +/** + * How far to scroll (added to `scrollTop`) to put the anchor back where it + * was captured. The first candidate still present decides; `topOf` returns + * undefined for an item that is gone. Zero when no candidate is left. + */ +export function anchorCorrection( + anchor: ConversationAnchor, + viewportTop: number, + topOf: (id: string) => number | undefined, +): number { + for (const candidate of anchor.candidates) { + const top = topOf(candidate.id); + if (top === undefined) continue; + return top - viewportTop - candidate.offset; + } + return 0; +} + +/** + * How far the anchored content has moved, in scroll coordinates, since the + * anchor was captured with the container at `capturedTop`: the anchor's + * correction now, plus however far `scrollTop` moved meanwhile without the + * conversation writing it (the browser clamping it as content above shrank). + * The first candidate still present decides; zero with none left, since then + * nothing says where the content went. + */ +export function anchorShift( + anchor: ConversationAnchor, + { + viewportTop, + scrollTop, + capturedTop, + }: { + readonly viewportTop: number; + readonly scrollTop: number; + readonly capturedTop: number; + }, + topOf: (id: string) => number | undefined, +): number { + for (const candidate of anchor.candidates) { + const top = topOf(candidate.id); + if (top === undefined) continue; + return scrollTop - capturedTop + top - viewportTop - candidate.offset; + } + return 0; +} + +/** + * Carries a glide in flight over content that moved under it by `shift` + * (`anchorShift`). The view goes back over the content it showed when the + * anchor was captured at `capturedTop`, and the start moves with the content, + * so `glidePosition` from the new start towards the re-resolved target traces + * the same path through the content, offset by `shift`, and lands on it. + */ +export function rebaseGlide({ + start, + capturedTop, + shift, +}: { + readonly start: number; + readonly capturedTop: number; + readonly shift: number; +}): { readonly start: number; readonly scrollTop: number } { + return { start: start + shift, scrollTop: capturedTop + shift }; +} + +/** + * The `scrollTop` that puts an item's top at the top of the view, below an + * optional `inset` (the container's `scroll-padding-top`, for a sticky + * header). `itemTop` and `viewportTop` are in the same client coordinates. + */ +export function settleTarget({ + scrollTop, + viewportTop, + itemTop, + inset = 0, +}: { + readonly scrollTop: number; + readonly viewportTop: number; + readonly itemTop: number; + readonly inset?: number; +}): number { + return scrollTop + itemTop - viewportTop - inset; +} + +/** + * Space to reserve below the content so a settled item can sit at the top of + * the view: the view's height minus everything from the item's top to the end + * of the content, never negative. As the reply below the item grows, the + * reserve shrinks by as much, so the scroll height (and the view) stays put + * until the reply outgrows the view. Arguments are in scroll coordinates; + * `viewportHeight` excludes any inset. + */ +export function reservedSpace({ + viewportHeight, + itemTop, + contentEnd, +}: { + readonly viewportHeight: number; + readonly itemTop: number; + readonly contentEnd: number; +}): number { + return Math.max(0, viewportHeight - (contentEnd - itemTop)); +} + +/** + * The reserve to keep once no item is settled (it was removed, or the policy + * changed): only the part the view still hangs below the end of the content, + * so the container can still reach `scrollTop` and the browser never clamps + * the view. As the view leaves that space, the rest is released too. Nothing + * at the top of the container, where no clamp can move the view. + */ +export function releasedReserve({ + scrollTop, + distanceFromEnd, +}: { + readonly scrollTop: number; + /** As `distanceFromEnd` measures it: negative inside the reserve. */ + readonly distanceFromEnd: number; +}): number { + return scrollTop <= 0 ? 0 : Math.max(0, -distanceFromEnd); +} + +/** How far the end of the content is below the bottom of the view; the + * reserved space after the content does not count as content. */ +export function distanceFromEnd({ + scrollHeight, + scrollTop, + clientHeight, + reserved = 0, +}: { + readonly scrollHeight: number; + readonly scrollTop: number; + readonly clientHeight: number; + readonly reserved?: number; +}): number { + return scrollHeight - reserved - scrollTop - clientHeight; +} + +/** Clamps a `scrollTop` to what the container can actually reach. */ +export function clampScrollTop( + value: number, + scrollHeight: number, + clientHeight: number, +): number { + return Math.min(Math.max(0, value), Math.max(0, scrollHeight - clientHeight)); +} + +/** Who moved the view. */ +export type ConversationScrollCause = "reader" | "self" | "layout"; + +/** + * Classifies one scroll event. A scroll that lands where the container last + * wrote `scrollTop` is the container's own (a follow, a glide frame, an anchor + * restore), even inside a reader's gesture. Otherwise a scroll inside an open + * gesture (`gestureUntil`, set from the reader's input only) is the reader's. + * Anything else is the layout's: content shrinking under the view, an image + * loading, or the page moving the view (focus, find in page). It never opens + * or extends the reader's gesture; the conversation decides from where it + * landed whether to keep it. + */ +export function classifyScroll({ + scrollTop, + written, + now, + gestureUntil, +}: { + readonly scrollTop: number; + readonly written: number | null; + readonly now: number; + readonly gestureUntil: number; +}): ConversationScrollCause { + if (written !== null && Math.abs(scrollTop - written) <= 1) return "self"; + if (now <= gestureUntil) return "reader"; + return "layout"; +} + +/** + * The reader's gesture window after one of their own scrolls. Momentum after + * a touch lifts (and a wheel's smooth-scroll animation) arrives as scroll + * events with no input, so inside a momentum phase, which lasts until + * `momentumUntil`, each of the reader's scrolls keeps the window open for + * another quiet period, never past `momentumUntil`. Outside one, a scroll + * never extends the window. Only `classifyScroll`'s "reader" scrolls count: + * the conversation's own writes never extend it. + */ +export function extendGesture({ + now, + gestureUntil, + momentumUntil, +}: { + readonly now: number; + readonly gestureUntil: number; + readonly momentumUntil: number; +}): number { + if (now > momentumUntil) return gestureUntil; + return Math.max( + gestureUntil, + Math.min(now + CONVERSATION_GESTURE_QUIET_MS, momentumUntil), + ); +} + +/** A glide's length: longer jumps take a little longer, never sluggish. */ +export function glideDuration(distance: number): number { + return Math.min(320 + Math.abs(distance) * 0.08, 720); +} + +export function easeOutCubic(progress: number): number { + return 1 - (1 - progress) ** 3; +} + +/** + * The glide's position at `progress` (0 to 1): eased from `start` towards a + * `target` that is re-measured every frame, so it lands exactly even while + * the content under it changes height. Content moving above the view moves + * `start` too (`rebaseGlide`). + */ +export function glidePosition( + start: number, + target: number, + progress: number, +): number { + return ( + start + (target - start) * easeOutCubic(Math.min(Math.max(progress, 0), 1)) + ); +} diff --git a/src/ui/conversation-divider.tsx b/src/ui/conversation-divider.tsx new file mode 100644 index 00000000..b6c0032e --- /dev/null +++ b/src/ui/conversation-divider.tsx @@ -0,0 +1,74 @@ +import type { ComponentProps, ReactNode } from "react"; + +import { cn } from "../lib/utils.js"; + +export type ConversationDividerProps = ComponentProps<"div">; + +/** + * A rule across the conversation with an optional label in the middle: a day + * ("Today", "March 3") or any other break. Without a label the rule is an + * `
`, a plain separator; with one, the label is ordinary text so it is + * read in place and the rules are decoration. + */ +export function ConversationDivider({ + className, + children, + ...props +}: ConversationDividerProps) { + const labelled = children !== undefined && children !== null; + return ( +
+ {labelled ? ( + <> + + {children} + + + ) : ( +
+ )} +
+ ); +} + +export type ConversationNoticeProps = ComponentProps<"p"> & { + /** A small decorative mark before the text; hidden from assistive tech. */ + readonly icon?: ReactNode; +}; + +/** + * A one-line system event in the conversation: someone joined, a run was + * scheduled, a setting changed. Centred and quiet, so it never reads as a + * message. + */ +export function ConversationNotice({ + icon, + className, + children, + ...props +}: ConversationNoticeProps) { + return ( +

+ {icon === undefined ? null : ( + + {icon} + + )} + {children} +

+ ); +} diff --git a/src/ui/conversation.test.tsx b/src/ui/conversation.test.tsx new file mode 100644 index 00000000..acb1aa0a --- /dev/null +++ b/src/ui/conversation.test.tsx @@ -0,0 +1,1354 @@ +import { act, createRef, memo, useEffect } from "react"; +import type { ReactElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { + Conversation, + ConversationContent, + ConversationEmptyState, + ConversationItem, + ConversationScrollButton, + useConversation, + type ConversationContextValue, + type ConversationProps, + type ConversationScrollPolicy, + type ConversationScrollSnapshot, +} from "./conversation.js"; +import { + ConversationDivider, + ConversationNotice, +} from "./conversation-divider.js"; +import { clampScrollTop } from "../lib/conversation-scroll.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(); + }, + }; +} + +const nextFrame = () => + act( + () => + new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }), + ); + +function scroller(container: HTMLElement): HTMLElement { + const element = container.querySelector( + "[data-slot='conversation-content']", + ); + if (element === null) throw new Error("expected the scroll container"); + return element; +} + +/** happy-dom does no layout: give the container a tall content box. */ +function giveGeometry( + element: HTMLElement, + scrollHeight: number, + clientHeight: number, +) { + Object.defineProperty(element, "scrollHeight", { + configurable: true, + value: scrollHeight, + }); + Object.defineProperty(element, "clientHeight", { + configurable: true, + value: clientHeight, + }); +} + +/** A Conversation over items `ids`, re-rendered with new props by `update`. */ +function mountConversation( + props: ConversationProps = {}, + ids: readonly string[] = ["a"], +) { + let api: ConversationContextValue | null = null; + function Probe() { + api = useConversation(); + return null; + } + const tree = (next: ConversationProps) => ( + + + {ids.map((id) => ( + + {id} + + ))} + + + + ); + const mounted = render(tree(props)); + const current = () => { + if (api === null) throw new Error("expected the conversation API"); + return api; + }; + const element = scroller(mounted.container); + const spacer = () => { + const found = element.querySelector( + "[data-slot='conversation-reserve']", + ); + if (found === null) throw new Error("expected the reserve"); + return found; + }; + return { + mounted, + element, + current, + spacer, + update: (next: ConversationProps) => mounted.rerender(tree(next)), + }; +} + +const mountPolicy = (policy: ConversationScrollPolicy) => + mountConversation({ scrollPolicy: policy }); + +const sleep = (ms: number) => + act(() => new Promise((resolve) => setTimeout(resolve, ms))); + +/** The reader's wheel: input on the container, then the scroll it makes. */ +const wheel = (element: HTMLElement, scrollTop: number) => { + element.dispatchEvent(new WheelEvent("wheel")); + element.scrollTop = scrollTop; + element.dispatchEvent(new Event("scroll")); +}; + +/** A flick: a finger down, a short drag, lifted; momentum follows. */ +const flick = (element: HTMLElement, scrollTop: number) => { + element.dispatchEvent(new Event("touchstart")); + element.dispatchEvent(new Event("touchmove")); + element.scrollTop = scrollTop; + element.dispatchEvent(new Event("scroll")); + element.dispatchEvent(new Event("touchend")); +}; + +/** A scroll no input explains: focus moving, or find in page. */ +const pageScroll = (element: HTMLElement, scrollTop: number) => { + element.scrollTop = scrollTop; + element.dispatchEvent(new Event("scroll")); +}; + +const ITEM_HEIGHT = 1_000; +const VIEW_HEIGHT = 500; +const TEN = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"] as const; + +/** + * Lays the items out as a browser would: stacked `ITEM_HEIGHT` apart in a + * `VIEW_HEIGHT` view, with `scrollTop` clamped to the content. `layout(height)` + * changes the content's height, clamping the view as the browser does. + */ +function layOut(element: HTMLElement, ids: readonly string[]) { + let height = ids.length * ITEM_HEIGHT; + let top = element.scrollTop; + Object.defineProperty(element, "scrollHeight", { + configurable: true, + get: () => height, + }); + Object.defineProperty(element, "clientHeight", { + configurable: true, + value: VIEW_HEIGHT, + }); + Object.defineProperty(element, "scrollTop", { + configurable: true, + get: () => top, + set: (value: number) => { + top = clampScrollTop(value, height, VIEW_HEIGHT); + }, + }); + element.getBoundingClientRect = () => + ({ top: 0, bottom: VIEW_HEIGHT, height: VIEW_HEIGHT }) as DOMRect; + ids.forEach((id, index) => { + const item = element.querySelector(`[data-item-id='${id}']`); + if (item === null) throw new Error(`expected item ${id}`); + item.getBoundingClientRect = () => { + const itemTop = index * ITEM_HEIGHT - top; + return { + top: itemTop, + bottom: itemTop + ITEM_HEIGHT, + height: ITEM_HEIGHT, + } as DOMRect; + }; + }); + return { + layout(next: number) { + height = next; + top = clampScrollTop(top, height, VIEW_HEIGHT); + }, + }; +} + +describe("Conversation context", () => { + test("scrolling 100 frames re-renders a memoised child zero times", async () => { + let renders = 0; + const Row = memo(function Row() { + renders += 1; + return

row

; + }); + const seen = new Set(); + function Probe() { + const value = useConversation(); + useEffect(() => { + seen.add(value); + }); + return null; + } + const mounted = render( + + + + + + + + , + ); + const element = scroller(mounted.container); + giveGeometry(element, 100_000, 500); + const rendersAfterMount = renders; + const valuesAfterMount = seen.size; + + for (let frame = 1; frame <= 100; frame += 1) { + // The reader's own scrolling, back and forth near the end. + element.dispatchEvent(new WheelEvent("wheel")); + element.scrollTop = 99_500 - (frame % 30); + element.dispatchEvent(new Event("scroll")); + await nextFrame(); + } + + expect(rendersAfterMount).toBe(1); + expect(renders).toBe(1); + // Every scroll stayed within reach of the end, so nothing the context + // shares changed, and its value kept its identity. + expect(seen.size).toBe(valuesAfterMount); + mounted.unmount(); + }); + + test("throws a clear error outside a Conversation", () => { + function Orphan() { + useConversation(); + return null; + } + const container = document.createElement("div"); + const root = createRoot(container); + const original = console.error; + console.error = () => {}; + try { + expect(() => act(() => root.render())).toThrow( + "useConversation must be used within ", + ); + } finally { + console.error = original; + act(() => root.unmount()); + } + }); +}); + +describe("ConversationItem registry", () => { + test("lists items in document order and drops unmounted ones", async () => { + let api: ConversationContextValue | null = null; + function Probe() { + api = useConversation(); + return null; + } + const list = (ids: readonly string[]) => ( + + + {ids.map((id) => ( + + {id} + + ))} + + + + ); + const mounted = render(list(["b", "c"])); + const items = () => { + if (api === null) throw new Error("expected the conversation API"); + return api.items; + }; + let notified = 0; + const unsubscribe = items().subscribe(() => { + notified += 1; + }); + + mounted.rerender(list(["a", "b", "c"])); + await act(() => Promise.resolve()); + expect(items().ids()).toEqual(["a", "b", "c"]); + expect(items().get("a")?.textContent).toBe("a"); + expect(notified).toBe(1); + + mounted.rerender(list(["b", "c"])); + await act(() => Promise.resolve()); + expect(items().ids()).toEqual(["b", "c"]); + expect(items().get("a")).toBeUndefined(); + expect(notified).toBe(2); + + unsubscribe(); + mounted.unmount(); + }); + + test("a null ref drops the item, which is how React 18 detaches", async () => { + let api: ConversationContextValue | null = null; + function Probe() { + api = useConversation(); + return null; + } + const mounted = render( + + + a + + + , + ); + const element = + mounted.container.querySelector("[data-item-id='a']"); + const fiberKey = Object.keys(element ?? {}).find((key) => + key.startsWith("__reactFiber$"), + ); + const fiber = + element === null || fiberKey === undefined + ? undefined + : (element as HTMLElement & Record)[ + fiberKey + ]; + const ref = fiber?.ref; + if (typeof ref !== "function") throw new Error("expected the item ref"); + const items = () => { + if (api === null) throw new Error("expected the conversation API"); + return api.items; + }; + + act(() => { + ref(null); + }); + await act(() => Promise.resolve()); + expect(items().get("a")).toBeUndefined(); + expect(items().ids()).toEqual([]); + mounted.unmount(); + }); +}); + +describe("ConversationScrollButton", () => { + test("appears when the end is out of view and hands focus to the log", async () => { + const mounted = render( + + + a + + + , + ); + const element = scroller(mounted.container); + const button = () => + mounted.container.querySelector( + "[data-slot='conversation-scroll-button']", + ); + expect(button()).toBeNull(); + + giveGeometry(element, 2000, 500); + element.dispatchEvent(new WheelEvent("wheel")); + element.scrollTop = 0; + element.dispatchEvent(new Event("scroll")); + await nextFrame(); + expect(button()?.getAttribute("aria-label")).toBe("Scroll to latest"); + + act(() => button()?.click()); + expect(document.activeElement).toBe(element); + mounted.unmount(); + }); + + test("reaching the end another way hands its focus to the log", async () => { + const mounted = render( + + + a + + + , + ); + const element = scroller(mounted.container); + const button = () => + mounted.container.querySelector( + "[data-slot='conversation-scroll-button']", + ); + giveGeometry(element, 2000, 500); + wheel(element, 0); + await nextFrame(); + act(() => button()?.focus()); + expect(document.activeElement === button()).toBe(true); + + // The reader wheels back to the end with the button focused: it leaves. + wheel(element, 1500); + await nextFrame(); + expect(button()).toBeNull(); + expect(document.activeElement === element).toBe(true); + mounted.unmount(); + }); +}); + +describe("ConversationContent", () => { + test("its ref, id, className and naming land on the scroll container", async () => { + let received: HTMLDivElement | null = null; + const mounted = render( + + { + received = node; + }} + id="log" + aria-label="Thread with the agent" + aria-describedby="hint" + className="scroll-pt-12" + > + a + + + , + ); + const element = scroller(mounted.container); + expect(received).not.toBeNull(); + expect(received === element).toBe(true); + expect(element.getAttribute("role")).toBe("log"); + expect(element.id).toBe("log"); + expect(element.getAttribute("aria-label")).toBe("Thread with the agent"); + expect(element.getAttribute("aria-describedby")).toBe("hint"); + expect(element.classList.contains("scroll-pt-12")).toBe(true); + const column = element.querySelector("[data-slot='conversation-column']"); + expect(column?.parentElement).toBe(element); + expect(column?.hasAttribute("id")).toBe(false); + + // The conversation still drives the same element through its own ref. + giveGeometry(element, 2000, 500); + element.dispatchEvent(new WheelEvent("wheel")); + element.scrollTop = 0; + element.dispatchEvent(new Event("scroll")); + await nextFrame(); + expect( + mounted.container.querySelector( + "[data-slot='conversation-scroll-button']", + ), + ).not.toBeNull(); + mounted.unmount(); + }); + + test("the log, an item and the scroll button forward a host ref", async () => { + // React 18 drops `ref` from a function component's props: only a + // forwardRef component receives it there. + for (const part of [ + ConversationContent, + ConversationItem, + ConversationScrollButton, + ]) { + expect(part.$$typeof).toBe(Symbol.for("react.forward_ref")); + } + const log = createRef(); + const item = createRef(); + const button = createRef(); + const mounted = render( + + + + a + + + + , + ); + const element = scroller(mounted.container); + expect(log.current === element).toBe(true); + expect(item.current?.dataset.itemId).toBe("a"); + expect(button.current).toBeNull(); + + giveGeometry(element, 2000, 500); + wheel(element, 0); + await nextFrame(); + expect(button.current?.dataset.slot).toBe("conversation-scroll-button"); + mounted.unmount(); + expect(log.current).toBeNull(); + expect(item.current).toBeNull(); + expect(button.current).toBeNull(); + }); + + test("names the log Conversation unless the host names it", () => { + const mounted = render( + + + , + ); + expect(scroller(mounted.container).getAttribute("aria-label")).toBe( + "Conversation", + ); + mounted.rerender( + + + , + ); + const element = scroller(mounted.container); + expect(element.getAttribute("aria-labelledby")).toBe("title"); + expect(element.hasAttribute("aria-label")).toBe(false); + mounted.unmount(); + }); +}); + +describe("useConversation scroll access", () => { + function mountWithApi() { + let api: ConversationContextValue | null = null; + const seen = new Set(); + function Probe() { + const value = useConversation(); + api = value; + useEffect(() => { + seen.add(value); + }); + return null; + } + const mounted = render( + + + a + + + , + ); + const current = () => { + if (api === null) throw new Error("expected the conversation API"); + return api; + }; + return { mounted, current, seen }; + } + + test("scrollContainer returns the role=log element", () => { + const { mounted, current } = mountWithApi(); + const element = current().scrollContainer(); + expect(element === scroller(mounted.container)).toBe(true); + expect(element?.getAttribute("role")).toBe("log"); + mounted.unmount(); + expect(current().scrollContainer()).toBeNull(); + }); + + test("subscribers get one snapshot per frame and the context keeps its identity", async () => { + const { mounted, current, seen } = mountWithApi(); + const element = scroller(mounted.container); + giveGeometry(element, 100_000, 500); + element.scrollTop = 99_500; + const valuesBefore = seen.size; + const snapshots: ConversationScrollSnapshot[] = []; + const unsubscribe = current().subscribeScroll((snapshot) => { + snapshots.push(snapshot); + }); + + // The mount is owed one snapshot; no reader scrolled. + await nextFrame(); + expect(snapshots).toHaveLength(1); + expect(snapshots[0]?.byReader).toBe(false); + expect(snapshots[0]?.delta).toBe(0); + expect(snapshots[0]?.distanceFromEnd).toBe(0); + const startTop = 99_500; + + // The reader scrolls up 20 times within one frame, near the end. + for (let step = 1; step <= 20; step += 1) { + element.dispatchEvent(new WheelEvent("wheel")); + element.scrollTop = startTop - step; + element.dispatchEvent(new Event("scroll")); + } + await nextFrame(); + expect(snapshots).toHaveLength(2); + expect(snapshots[1]).toEqual({ + scrollTop: startTop - 20, + scrollHeight: 100_000, + clientHeight: 500, + distanceFromEnd: 20, + delta: -20, + byReader: true, + }); + + // A frame without scrolls notifies nobody. + await nextFrame(); + expect(snapshots).toHaveLength(2); + + // Once the reader's gesture has gone quiet, a scroll is not theirs. The + // view still follows the end, so the conversation puts it back there. + await act(() => new Promise((resolve) => setTimeout(resolve, 200))); + element.scrollTop = startTop - 25; + element.dispatchEvent(new Event("scroll")); + await nextFrame(); + expect(snapshots).toHaveLength(3); + expect(snapshots[2]?.byReader).toBe(false); + expect(snapshots[2]?.scrollTop).toBe(startTop); + expect(snapshots[2]?.delta).toBe(20); + + // Every scroll stayed near the end, so the context value never changed. + expect(seen.size).toBe(valuesBefore); + + unsubscribe(); + element.dispatchEvent(new WheelEvent("wheel")); + element.scrollTop = startTop - 30; + element.dispatchEvent(new Event("scroll")); + await nextFrame(); + expect(snapshots).toHaveLength(3); + mounted.unmount(); + }); +}); + +describe("reader attribution and scroll listeners", () => { + test("the conversation's own follow writes never count as the reader's", async () => { + const { mounted, element, current } = mountPolicy("stick"); + let scrollHeight = 10_000; + giveGeometry(element, scrollHeight, 500); + element.scrollTop = scrollHeight - 500; + const snapshots: ConversationScrollSnapshot[] = []; + current().subscribeScroll((snapshot) => snapshots.push(snapshot)); + await nextFrame(); + + // One wheel tick, still at the end: the view keeps following. + wheel(element, scrollHeight - 501); + await nextFrame(); + expect(snapshots.at(-1)?.byReader).toBe(true); + const afterWheel = snapshots.length; + + // Streaming every 100ms: the conversation follows the end (as on a resize + // callback), and its scroll event lands, the first inside the wheel's + // window. + for (let round = 0; round < 15; round += 1) { + await sleep(100); + scrollHeight += 20; + giveGeometry(element, scrollHeight, 500); + current().restoreAnchor(); + element.dispatchEvent(new Event("scroll")); + await nextFrame(); + } + const streamed = snapshots.slice(afterWheel); + expect(streamed).toHaveLength(15); + expect(streamed.every((snapshot) => !snapshot.byReader)).toBe(true); + expect(snapshots.at(-1)?.scrollTop).toBe(scrollHeight - 500); + mounted.unmount(); + }); + + test("momentum after a flick stays the reader's and keeps a follow broken", async () => { + const { mounted, element, current } = mountPolicy("stick"); + giveGeometry(element, 10_000, 500); + element.scrollTop = 9_500; + const snapshots: ConversationScrollSnapshot[] = []; + current().subscribeScroll((snapshot) => snapshots.push(snapshot)); + await nextFrame(); + const afterMount = snapshots.length; + + // The drag itself stays within the end threshold: still following. + flick(element, 9_490); + await nextFrame(); + // Momentum: no input, a scroll every 50ms for 800ms, away from the end. + let top = 9_490; + for (let tick = 0; tick < 16; tick += 1) { + await sleep(50); + top -= 40; + element.scrollTop = top; + element.dispatchEvent(new Event("scroll")); + await nextFrame(); + } + const flicked = snapshots.slice(afterMount); + expect(flicked).toHaveLength(17); + expect(flicked.every((snapshot) => snapshot.byReader)).toBe(true); + // The follow broke and stays broken: content growing does not pull the + // view back to the end. + expect(element.scrollTop).toBe(top); + giveGeometry(element, 10_200, 500); + current().restoreAnchor(); + expect(element.scrollTop).toBe(top); + mounted.unmount(); + }); + + test("scrolls after the momentum cap are not the reader's", async () => { + // A clock the test moves, so a slow machine can't stretch the gap + // between two scrolls past the gesture's quiet window. + let now = 0; + const originalNow = performance.now.bind(performance); + performance.now = () => now; + try { + const { mounted, element, current } = mountPolicy("stick"); + giveGeometry(element, 10_000, 500); + element.scrollTop = 9_500; + const seen: { at: number; byReader: boolean }[] = []; + current().subscribeScroll((snapshot) => + seen.push({ at: now, byReader: snapshot.byReader }), + ); + await nextFrame(); + + flick(element, 9_000); + const lifted = now; + let top = 9_000; + while (now - lifted < 2_600) { + now += 100; + top -= 10; + element.scrollTop = top; + element.dispatchEvent(new Event("scroll")); + await nextFrame(); + } + const early = seen.filter(({ at }) => at > lifted && at - lifted < 2_000); + const late = seen.filter(({ at }) => at - lifted > 2_000); + expect(early).toHaveLength(19); + expect(early.every(({ byReader }) => byReader)).toBe(true); + expect(late).toHaveLength(6); + expect(late.every(({ byReader }) => !byReader)).toBe(true); + mounted.unmount(); + } finally { + performance.now = originalNow; + } + }); + + test("a glide started inside the reader's window is not the reader's", async () => { + const { mounted, element, current } = mountPolicy("settle"); + giveGeometry(element, 10_000, 500); + element.scrollTop = 9_500; + const snapshots: ConversationScrollSnapshot[] = []; + current().subscribeScroll((snapshot) => snapshots.push(snapshot)); + await nextFrame(); + + wheel(element, 5_000); + current().scrollToEnd(); + await nextFrame(); + expect(snapshots.at(-1)?.byReader).toBe(true); + const afterWheel = snapshots.length; + for (let frame = 0; frame < 5; frame += 1) { + element.dispatchEvent(new Event("scroll")); + await nextFrame(); + } + const glided = snapshots.slice(afterWheel); + expect(glided.length).toBeGreaterThan(0); + expect(glided.every((snapshot) => !snapshot.byReader)).toBe(true); + expect(element.scrollTop).toBeGreaterThan(5_000); + mounted.unmount(); + }); + + test("a throwing listener neither stops the others nor the atBottom update", async () => { + const { mounted, element, current } = mountPolicy("settle"); + giveGeometry(element, 10_000, 500); + element.scrollTop = 9_500; + await nextFrame(); + const reported: unknown[] = []; + const original = globalThis.reportError; + globalThis.reportError = (error: unknown) => { + reported.push(error); + }; + try { + let second = 0; + const failure = new Error("listener failed"); + current().subscribeScroll(() => { + throw failure; + }); + current().subscribeScroll(() => { + second += 1; + }); + wheel(element, 1_000); + await nextFrame(); + expect(second).toBe(1); + expect(reported).toEqual([failure]); + expect(current().atBottom).toBe(false); + } finally { + globalThis.reportError = original; + mounted.unmount(); + } + }); + + test("each listener is called once per frame, even if it resubscribes", async () => { + const { mounted, element, current } = mountPolicy("settle"); + giveGeometry(element, 10_000, 500); + element.scrollTop = 9_500; + const calls: ConversationScrollSnapshot[] = []; + let unsubscribe = () => {}; + let resubscribed = false; + const listener = (snapshot: ConversationScrollSnapshot) => { + calls.push(snapshot); + if (resubscribed) return; + resubscribed = true; + unsubscribe(); + unsubscribe = current().subscribeScroll(listener); + }; + unsubscribe = current().subscribeScroll(listener); + await nextFrame(); + expect(calls).toHaveLength(1); + + // A listener unsubscribed by an earlier one in the same frame is skipped. + let lateCalls = 0; + let unsubscribeLate = () => {}; + current().subscribeScroll(() => unsubscribeLate()); + unsubscribeLate = current().subscribeScroll(() => { + lateCalls += 1; + }); + wheel(element, 9_000); + await nextFrame(); + expect(calls).toHaveLength(2); + expect(lateCalls).toBe(0); + unsubscribe(); + mounted.unmount(); + }); +}); + +describe("a scroll no input explains", () => { + for (const policy of ["settle", "stick"] as const) { + test(`leaving the end while following is kept and breaks the follow (${policy})`, async () => { + const { mounted, element, current } = mountPolicy(policy); + giveGeometry(element, 10_000, 500); + element.scrollTop = 9_500; + await nextFrame(); + + // Focus moving to an earlier item scrolls it into view, with no wheel, + // touch, key or pointer on the log. + element.scrollTop = 7_000; + element.dispatchEvent(new Event("scroll")); + await nextFrame(); + expect(element.scrollTop).toBe(7_000); + expect(current().atBottom).toBe(false); + + // The follow is broken: growth no longer pulls the view to the end. + giveGeometry(element, 10_200, 500); + current().restoreAnchor(); + expect(element.scrollTop).toBe(7_000); + mounted.unmount(); + }); + } +}); + +describe("a glide and the reader's scrolls", () => { + test("reader momentum during a glide cancels it", async () => { + const { mounted, element, current } = mountPolicy("stick"); + giveGeometry(element, 10_000, 500); + element.scrollTop = 9_500; + const snapshots: ConversationScrollSnapshot[] = []; + current().subscribeScroll((snapshot) => snapshots.push(snapshot)); + await nextFrame(); + flick(element, 9_000); + await nextFrame(); + current().scrollToEnd(); + await nextFrame(); + + // The flick's momentum keeps moving the view up under the glide. + element.scrollTop = 8_000; + element.dispatchEvent(new Event("scroll")); + await nextFrame(); + expect(snapshots.at(-1)?.byReader).toBe(true); + await sleep(800); + expect(element.scrollTop).toBe(8_000); + mounted.unmount(); + }); + + test("momentum that outlasts the cancelled glide stays the reader's", async () => { + const { mounted, element, current } = mountPolicy("stick"); + giveGeometry(element, 10_000, 500); + element.scrollTop = 9_500; + const snapshots: ConversationScrollSnapshot[] = []; + current().subscribeScroll((snapshot) => snapshots.push(snapshot)); + await nextFrame(); + flick(element, 9_000); + await nextFrame(); + current().scrollToEnd(); + + // A tick every 50ms through what would have been the glide and after it. + for (let tick = 0; tick < 16; tick += 1) { + await sleep(50); + const top = element.scrollTop - 30; + element.scrollTop = top; + element.dispatchEvent(new Event("scroll")); + await nextFrame(); + expect(element.scrollTop).toBe(top); + expect(snapshots.at(-1)?.byReader).toBe(true); + } + mounted.unmount(); + }); + + test("a key that does not scroll leaves a glide alone", async () => { + const { mounted, element, current } = mountPolicy("settle"); + giveGeometry(element, 10_000, 500); + element.scrollTop = 1_000; + await nextFrame(); + current().scrollToEnd(); + await nextFrame(); + await nextFrame(); + element.dispatchEvent( + new KeyboardEvent("keydown", { key: "Shift", bubbles: true }), + ); + await sleep(900); + expect(element.scrollTop).toBe(9_500); + mounted.unmount(); + }); + + test("Space on a control inside an item leaves a glide alone", async () => { + const { mounted, element, current } = mountPolicy("settle"); + giveGeometry(element, 10_000, 500); + element.scrollTop = 1_000; + const button = document.createElement("button"); + element.querySelector("[data-slot='conversation-item']")?.append(button); + await nextFrame(); + current().scrollToEnd(); + await nextFrame(); + await nextFrame(); + // Space activates the button; it does not scroll the log. + button.dispatchEvent( + new KeyboardEvent("keydown", { key: " ", bubbles: true }), + ); + await sleep(900); + expect(element.scrollTop).toBe(9_500); + + // An arrow key on the same button does scroll the log. + element.scrollTop = 1_000; + current().scrollToEnd(); + await nextFrame(); + await nextFrame(); + button.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }), + ); + const stoppedAt = element.scrollTop; + await sleep(900); + expect(element.scrollTop).toBe(stoppedAt); + expect(stoppedAt).toBeLessThan(9_500); + mounted.unmount(); + }); + + test("a scroll key cancels a glide", async () => { + const { mounted, element, current } = mountPolicy("settle"); + giveGeometry(element, 10_000, 500); + element.scrollTop = 1_000; + await nextFrame(); + current().scrollToEnd(); + await nextFrame(); + await nextFrame(); + element.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }), + ); + const stoppedAt = element.scrollTop; + await sleep(900); + expect(element.scrollTop).toBe(stoppedAt); + expect(stoppedAt).toBeLessThan(9_500); + mounted.unmount(); + }); +}); + +describe("scrollToItem's onEnd", () => { + test("reports arriving, being cancelled and being replaced", async () => { + const { mounted, element, current } = mountConversation({}, TEN); + layOut(element, TEN); + const ends: string[] = []; + const report = (label: string) => (arrived: boolean) => + ends.push(`${label}:${arrived}`); + await nextFrame(); + + current().scrollToItem("b", { onEnd: report("b") }); + await sleep(900); + expect(element.scrollTop).toBe(1_000); + expect(ends).toEqual(["b:true"]); + + // The reader's wheel stops the glide, even without moving the view. + current().scrollToItem("h", { onEnd: report("h") }); + await nextFrame(); + element.dispatchEvent(new WheelEvent("wheel")); + expect(ends).toEqual(["b:true", "h:false"]); + + // Another glide replaces the one in flight. + current().scrollToItem("j", { onEnd: report("j") }); + current().scrollToEnd(); + expect(ends).toEqual(["b:true", "h:false", "j:false"]); + + // An id with nothing mounted never glides. + current().scrollToItem("elsewhere", { onEnd: report("elsewhere") }); + expect(ends).toEqual(["b:true", "h:false", "j:false", "elsewhere:false"]); + await sleep(900); + expect(ends).toHaveLength(4); + mounted.unmount(); + }); + + test("a target that unmounts mid-glide is not an arrival", async () => { + let ids = ["a", "b"]; + let api: ConversationContextValue | null = null; + function Probe() { + api = useConversation(); + return null; + } + const tree = () => ( + + + {ids.map((id) => ( + + {id} + + ))} + + + + ); + const mounted = render(tree()); + const element = scroller(mounted.container); + layOut(element, ids); + await nextFrame(); + let arrived: boolean | undefined; + // The probe assigns `api` from a child, which control-flow analysis + // does not see, so a null check would narrow it to `never`. + const conversation = api as ConversationContextValue | null; + if (conversation === null) throw new Error("expected the conversation API"); + conversation.scrollToItem("b", { + onEnd: (value) => { + arrived = value; + }, + }); + ids = ["a"]; + mounted.rerender(tree()); + await sleep(900); + expect(arrived).toBe(false); + expect(element.scrollTop).toBe(0); + mounted.unmount(); + }); + + test("an id with nothing mounted keeps the follow and the glide in flight", async () => { + const { mounted, element, current } = mountConversation( + { scrollPolicy: "stick" }, + TEN, + ); + const { layout } = layOut(element, TEN); + current().restoreAnchor(); + await nextFrame(); + expect(element.scrollTop).toBe(9_500); + let arrived: boolean | undefined; + current().scrollToItem("elsewhere", { + onEnd: (value) => { + arrived = value; + }, + }); + expect(arrived).toBe(false); + + // A reply streams on: the view still follows it. + layout(10_200); + current().restoreAnchor(); + expect(element.scrollTop).toBe(9_700); + + // A glide in flight carries on to its item. + const ends: string[] = []; + current().scrollToItem("b", { + onEnd: (value) => ends.push(`b:${value}`), + }); + await nextFrame(); + current().scrollToItem("elsewhere"); + await sleep(900); + expect(ends).toEqual(["b:true"]); + expect(element.scrollTop).toBe(1_000); + mounted.unmount(); + }); +}); + +describe("a scroll right after a resize", () => { + const OriginalResizeObserver = globalThis.ResizeObserver; + let resized: (() => void)[] = []; + beforeEach(() => { + resized = []; + globalThis.ResizeObserver = class { + constructor(callback: () => void) { + resized.push(callback); + } + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + }); + afterEach(() => { + globalThis.ResizeObserver = OriginalResizeObserver; + }); + /** The resize callback the browser runs after a layout change. */ + const fireResize = () => + act(() => { + for (const callback of resized) callback(); + }); + + test("following, a page scroll away from the end is kept (stick)", async () => { + const { mounted, element, current } = mountConversation( + { scrollPolicy: "stick" }, + TEN, + ); + const { layout } = layOut(element, TEN); + fireResize(); + await sleep(150); + expect(element.scrollTop).toBe(9_500); + + // Streaming: the content grows and the view follows it. + layout(10_040); + fireResize(); + expect(element.scrollTop).toBe(9_540); + // Find in page moves the view to an earlier item before the next chunk. + pageScroll(element, 2_000); + await nextFrame(); + expect(element.scrollTop).toBe(2_000); + expect(current().atBottom).toBe(false); + + // The follow is broken: the next chunk leaves the view where it is. + layout(10_080); + fireResize(); + expect(element.scrollTop).toBe(2_000); + mounted.unmount(); + }); + + test("not following, a page scroll survives the next resize (settle)", async () => { + const { mounted, element } = mountConversation({}, TEN); + const { layout } = layOut(element, TEN); + await nextFrame(); + wheel(element, 6_000); + await sleep(200); + + layout(10_040); + fireResize(); + expect(element.scrollTop).toBe(6_000); + pageScroll(element, 2_000); + await nextFrame(); + expect(element.scrollTop).toBe(2_000); + + layout(10_080); + fireResize(); + expect(element.scrollTop).toBe(2_000); + mounted.unmount(); + }); + + test("the resize's own clamp is not taken as where the reader is", async () => { + const { mounted, element } = mountConversation({}, TEN); + const { layout } = layOut(element, TEN); + await nextFrame(); + wheel(element, 6_000); + await sleep(200); + + // Content below the view collapses: the browser clamps the view to the + // new end, and the resize callback cannot put it back yet. + layout(6_300); + fireResize(); + pageScroll(element, element.scrollTop); + expect(element.scrollTop).toBe(5_800); + await nextFrame(); + + // Once the content is back, so is the view. + layout(10_000); + fireResize(); + expect(element.scrollTop).toBe(6_000); + mounted.unmount(); + }); +}); + +describe("a remounted log", () => { + /** Lays the items out as soon as the log mounts, before it attaches. */ + const layOutTen = (node: HTMLDivElement | null) => { + if (node !== null) layOut(node, TEN); + }; + const tree = (policy: ConversationScrollPolicy, mount: number) => ( + + + {TEN.map((id) => ( + + {id} + + ))} + + + ); + + for (const policy of ["settle", "stick"] as const) { + test(`opens where the reader was, not at the top (${policy})`, async () => { + const mounted = render(tree(policy, 0)); + const before = scroller(mounted.container); + await nextFrame(); + expect(before.scrollTop).toBe(9_500); + wheel(before, 6_000); + await nextFrame(); + + mounted.rerender(tree(policy, 1)); + const after = scroller(mounted.container); + expect(after === before).toBe(false); + expect(after.scrollTop).toBe(6_000); + mounted.unmount(); + }); + } + + test("opens at the end while it follows", async () => { + const mounted = render(tree("stick", 0)); + await nextFrame(); + mounted.rerender(tree("stick", 1)); + expect(scroller(mounted.container).scrollTop).toBe(9_500); + mounted.unmount(); + }); +}); + +describe("sentId", () => { + test("the value at mount is history: nothing settles, the view follows", async () => { + const { mounted, element, current, spacer, update } = mountConversation( + { sentId: "a" }, + ["a", "b"], + ); + giveGeometry(element, 10_000, 500); + element.scrollTop = 9_500; + await nextFrame(); + expect(spacer().style.height).toBe(""); + giveGeometry(element, 10_200, 500); + current().restoreAnchor(); + expect(element.scrollTop).toBe(9_700); + + // The next change is a send. + update({ sentId: "b" }); + expect(spacer().style.height).toBe("500px"); + mounted.unmount(); + }); + + test("an id that is not an item does not glide under stick", async () => { + const { mounted, element, update } = mountConversation({ + scrollPolicy: "stick", + }); + giveGeometry(element, 10_000, 500); + element.scrollTop = 9_500; + await nextFrame(); + wheel(element, 1_000); + await sleep(200); + update({ scrollPolicy: "stick", sentId: "elsewhere" }); + await sleep(800); + expect(element.scrollTop).toBe(1_000); + mounted.unmount(); + }); + + test("an id that is not an item keeps the reserve under settle", async () => { + const { mounted, element, spacer, update } = mountConversation(); + giveGeometry(element, 10_000, 500); + element.scrollTop = 9_500; + await nextFrame(); + update({ sentId: "a" }); + expect(spacer().style.height).toBe("500px"); + update({ sentId: "elsewhere" }); + await act(() => Promise.resolve()); + expect(spacer().style.height).toBe("500px"); + expect(element.scrollTop).toBe(9_500); + mounted.unmount(); + }); + + test("an id that is not an item keeps the follow under settle", async () => { + const { mounted, element, current, update } = mountConversation(); + giveGeometry(element, 10_000, 500); + element.scrollTop = 9_500; + await nextFrame(); + update({ sentId: "elsewhere" }); + giveGeometry(element, 10_200, 500); + current().restoreAnchor(); + expect(element.scrollTop).toBe(9_700); + mounted.unmount(); + }); +}); + +describe("scrollPolicy changing mid-life", () => { + test("stick to settle: back at the end, the view no longer follows", async () => { + const { mounted, element, current, update } = mountPolicy("stick"); + giveGeometry(element, 10_000, 500); + element.scrollTop = 9_500; + await nextFrame(); + update({ scrollPolicy: "settle" }); + expect( + mounted.container + .querySelector("[data-slot='conversation']") + ?.getAttribute("data-scroll-policy"), + ).toBe("settle"); + wheel(element, 1_000); + wheel(element, 9_500); + giveGeometry(element, 10_200, 500); + current().restoreAnchor(); + expect(element.scrollTop).toBe(9_500); + mounted.unmount(); + }); + + test("settle to stick: following is taken from where the view is", async () => { + const { mounted, element, current, update } = mountPolicy("settle"); + giveGeometry(element, 10_000, 500); + element.scrollTop = 9_500; + await nextFrame(); + // Under settle, the reader leaving and coming back does not re-follow. + wheel(element, 1_000); + wheel(element, 9_500); + update({ scrollPolicy: "stick" }); + giveGeometry(element, 10_200, 500); + current().restoreAnchor(); + expect(element.scrollTop).toBe(9_700); + + // Switched while away from the end, it does not follow. + wheel(element, 1_000); + update({ scrollPolicy: "settle" }); + update({ scrollPolicy: "stick" }); + giveGeometry(element, 10_400, 500); + current().restoreAnchor(); + expect(element.scrollTop).toBe(1_000); + mounted.unmount(); + }); +}); + +describe("ConversationEmptyState", () => { + test("shows a default title, or the host's children instead", () => { + const mounted = render( + , + ); + const slot = mounted.container.querySelector( + "[data-slot='conversation-empty-state']", + ); + expect(slot?.textContent).toContain("No messages yet"); + expect(slot?.textContent).toContain("Ask the agent anything."); + + mounted.rerender( + +

Start a workflow

+
, + ); + const replaced = mounted.container.querySelector( + "[data-slot='conversation-empty-state']", + ); + expect(replaced?.textContent).toBe("Start a workflow"); + expect(replaced?.querySelector("[data-slot='empty-state']")).toBeNull(); + mounted.unmount(); + }); +}); + +describe("ConversationDivider and ConversationNotice", () => { + test("a bare divider is an hr; a labelled one reads its label", () => { + const mounted = render( + <> + + Today + , + ); + const bare = mounted.container.querySelector("[data-testid='bare']"); + const day = mounted.container.querySelector("[data-testid='day']"); + expect(bare?.hasAttribute("role")).toBe(false); + expect(bare?.querySelectorAll("hr")).toHaveLength(1); + expect(day?.hasAttribute("role")).toBe(false); + expect(day?.querySelector("hr")).toBeNull(); + expect(day?.textContent).toBe("Today"); + mounted.unmount(); + }); + + test("a notice hides its decorative icon", () => { + const mounted = render( + }> + A run was scheduled + , + ); + const mark = mounted.container.querySelector("[data-testid='mark']"); + expect(mark?.parentElement?.getAttribute("aria-hidden")).toBe("true"); + expect(mounted.container.textContent).toBe("A run was scheduled"); + mounted.unmount(); + }); +}); diff --git a/src/ui/conversation.tsx b/src/ui/conversation.tsx new file mode 100644 index 00000000..a5db319d --- /dev/null +++ b/src/ui/conversation.tsx @@ -0,0 +1,1092 @@ +"use client"; + +import { ArrowDown } from "lucide-react"; +import { + createContext, + forwardRef, + useCallback, + useContext, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import type { ComponentProps, ReactNode, Ref, RefCallback } from "react"; + +import { usePrefersReducedMotion } from "../hooks/use-prefers-reduced-motion.js"; +import { + CONVERSATION_END_THRESHOLD, + CONVERSATION_GESTURE_QUIET_MS, + CONVERSATION_MOMENTUM_MS, + anchorCorrection, + anchorShift, + captureAnchor, + clampScrollTop, + classifyScroll, + distanceFromEnd, + extendGesture, + glideDuration, + glidePosition, + rebaseGlide, + releasedReserve, + reservedSpace, + settleTarget, + type ConversationAnchor, +} from "../lib/conversation-scroll.js"; +import { cn } from "../lib/utils.js"; +import { EmptyState } from "./empty-state.js"; + +/** + * How the view moves as a conversation grows. + * + * - `settle`: a sent item glides to the top of the view and the reply writes + * in below it, into space reserved for it; streaming never moves the view. + * - `stick`: the view follows new content while the reader is at the end, and + * stays put once they scroll away. + */ +export type ConversationScrollPolicy = "settle" | "stick"; + +/** + * The registered items, read without re-rendering: `get` and `ids` read the + * current registry, `subscribe` calls back (once per batch) when an item + * registers or unregisters. + */ +export interface ConversationItems { + get(id: string): HTMLElement | undefined; + /** Registered ids in document order. */ + ids(): readonly string[]; + subscribe(listener: () => void): () => void; +} + +/** + * The scroll container's geometry after a frame in which it scrolled, as + * `subscribeScroll` hands it to its listeners. + */ +export type ConversationScrollSnapshot = { + readonly scrollTop: number; + readonly scrollHeight: number; + readonly clientHeight: number; + /** How far the end of the content is below the bottom of the view, not + * counting the space `settle` reserves after it; at most + * `CONVERSATION_END_THRESHOLD` means the end is in view. */ + readonly distanceFromEnd: number; + /** `scrollTop` minus its value at the previous notification (0 on the + * first): negative when the view moved towards the top. */ + readonly delta: number; + /** At least one scroll since the previous notification was the reader's + * own: it came while a finger or the scrollbar was held, within + * `CONVERSATION_GESTURE_QUIET_MS` of their wheel, touch or key input, or + * as momentum continuing a lifted touch or a wheel (at most + * `CONVERSATION_MOMENTUM_MS` after it). The conversation's own writes + * (following, glides, anchoring) never count, even then, and neither does + * the layout or the page moving the view (focus, find in page), even when + * the conversation keeps where it put the view. */ + readonly byReader: boolean; +}; + +export type ConversationScrollToItemOptions = { + /** Called once when the glide ends: `true` when it reached the item, + * `false` when it never started (no such item mounted) or was cut short by + * the reader's input or scroll, or by another glide. */ + readonly onEnd?: ((arrived: boolean) => void) | undefined; +}; + +export type ConversationContextValue = { + /** The end of the content is in view (within a few pixels). */ + readonly atBottom: boolean; + readonly items: ConversationItems; + /** Glides an item's top to the top of the view. An id with no mounted item + * changes nothing: a follow and any glide in flight carry on. */ + readonly scrollToItem: ( + id: string, + options?: ConversationScrollToItemOptions, + ) => void; + /** Glides to the end of the content. */ + readonly scrollToEnd: () => void; + /** Records which items are in view and where, right now; call it just + * before changing content above the reader. */ + readonly captureAnchor: () => void; + /** Puts the captured items back where they were; call it in a layout + * effect after the change. Resizes keep it in place until the next jump. + * Mid-glide, the glide moves with the content and still lands on its + * target. */ + readonly restoreAnchor: () => void; + /** The scroll container (the `ConversationContent` element, role `log`), + * or `null` while none is mounted. */ + readonly scrollContainer: () => HTMLElement | null; + /** Calls `listener` at most once per animation frame: once after the + * container mounts, then after each frame in which it scrolled. Returns the + * unsubscribe function. */ + readonly subscribeScroll: ( + listener: (snapshot: ConversationScrollSnapshot) => void, + ) => () => void; +}; + +type ItemRegistry = ConversationItems & { + register(id: string, element: HTMLElement): () => void; +}; + +function createItemRegistry(): ItemRegistry { + const elements = new Map(); + const listeners = new Set<() => void>(); + let ordered: readonly string[] | null = null; + let pending = false; + + const notify = () => { + ordered = null; + if (pending) return; + pending = true; + queueMicrotask(() => { + pending = false; + for (const listener of listeners) listener(); + }); + }; + + return { + get: (id) => elements.get(id), + // Sorted on read, then cached until the registry changes: items mount in + // any order (a page of history lands above), but are read on every scroll. + ids() { + ordered ??= [...elements.entries()] + .sort(([, a], [, b]) => + a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING + ? -1 + : 1, + ) + .map(([id]) => id); + return ordered; + }, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + register(id, element) { + elements.set(id, element); + notify(); + return () => { + if (elements.get(id) !== element) return; + elements.delete(id); + notify(); + }; + }, + }; +} + +/** + * Calls `call` for each listener subscribed when notification starts, once + * each, skipping any unsubscribed by an earlier one. A listener that throws + * does not stop the others: its error is reported asynchronously instead. + */ +function notifyAll(listeners: ReadonlySet, call: (listener: T) => void) { + // A copy: listeners may subscribe or unsubscribe while being notified. + for (const listener of Array.from(listeners)) { + if (!listeners.has(listener)) continue; + try { + call(listener); + } catch (error) { + if (typeof reportError === "function") reportError(error); + else + queueMicrotask(() => { + throw error; + }); + } + } +} + +/** The keys that scroll a focused container vertically. */ +const SCROLL_KEYS: ReadonlySet = new Set([ + "ArrowUp", + "ArrowDown", + "PageUp", + "PageDown", + "Home", + "End", + " ", +]); + +function isEditable(target: EventTarget | null): boolean { + return ( + target instanceof HTMLElement && + (target.isContentEditable || target.matches("input, textarea, select")) + ); +} + +/** Controls that Space activates instead of scrolling the log they sit in. + * Their other scroll keys (arrows, Page, Home, End) still scroll it. */ +const SPACE_ACTIVATED = + "button, summary, [role=button], [role=checkbox], [role=switch], [role=radio], [role=tab], [role=menuitem], [role=option]"; + +/** The key would scroll the focused log, rather than act on its target. */ +function isScrollKey(event: KeyboardEvent): boolean { + if (!SCROLL_KEYS.has(event.key) || isEditable(event.target)) return false; + return !( + event.key === " " && + event.target instanceof Element && + event.target.matches(SPACE_ACTIVATED) + ); +} + +/** + * The scrolling machinery, outside React: it reads and writes the DOM on + * scroll, resize and animation frames, where re-rendering would be both too + * slow and pointless. React only attaches it and feeds it props. + */ +function createConversationEngine() { + const registry = createItemRegistry(); + const items: ConversationItems = { + get: registry.get, + ids: registry.ids, + subscribe: registry.subscribe, + }; + let scroller: HTMLElement | null = null; + let spacer: HTMLElement | null = null; + let policy: ConversationScrollPolicy = "settle"; + let reducedMotion = false; + /** The view tracks the end. Both policies start here, so history that + * arrives after mount still opens at the latest; `settle` leaves it at the + * first send or the reader's first scroll away, `stick` whenever the reader + * scrolls away and back. */ + let following = true; + let anchor: ConversationAnchor = { candidates: [] }; + /** `scrollTop` when `anchor` was captured. */ + let anchorTop = 0; + /** The last `scrollTop` this engine wrote, until its scroll event lands. */ + let written: number | null = null; + /** The reader's gesture is open until then; only their input moves it. */ + let gestureUntil = 0; + /** A finger or the scrollbar is down: the gesture stays open meanwhile. */ + let pointerHeld = false; + let touchHeld = false; + /** The momentum phase after a touch lifts or a wheel tick ends then. */ + let momentumUntil = 0; + let resizedAt = Number.NEGATIVE_INFINITY; + let reserved = 0; + let settledId: string | null = null; + let glideToken = 0; + let gliding = false; + /** The glide in flight's `onEnd`, called once as it ends. */ + let glideEnd: ((arrived: boolean) => void) | undefined; + /** Where the glide in flight eases from; moves with the content under it. */ + let glideStart = 0; + let frame = 0; + let atBottom = true; + const atBottomListeners = new Set<() => void>(); + const scrollListeners = new Set< + (snapshot: ConversationScrollSnapshot) => void + >(); + /** Scroll listeners are owed a snapshot at the next frame. */ + let scrolled = false; + let scrolledByReader = false; + let notifiedTop: number | null = null; + let sentSeeded = false; + let lastSent: string | undefined; + + const write = (value: number) => { + if (scroller === null) return; + const before = scroller.scrollTop; + scroller.scrollTop = value; + if (scroller.scrollTop !== before) written = scroller.scrollTop; + }; + + const distance = () => + scroller === null + ? 0 + : distanceFromEnd({ + scrollHeight: scroller.scrollHeight, + scrollTop: scroller.scrollTop, + clientHeight: scroller.clientHeight, + reserved, + }); + + const endTop = () => + scroller === null + ? 0 + : clampScrollTop( + scroller.scrollHeight - reserved - scroller.clientHeight, + scroller.scrollHeight, + scroller.clientHeight, + ); + + const inset = () => { + if (scroller === null) return 0; + const value = Number.parseFloat( + getComputedStyle(scroller).scrollPaddingTop, + ); + return Number.isFinite(value) ? value : 0; + }; + + const itemTarget = (id: string): number | null => { + const element = registry.get(id); + if (scroller === null || element === undefined) return null; + return clampScrollTop( + settleTarget({ + scrollTop: scroller.scrollTop, + viewportTop: scroller.getBoundingClientRect().top, + itemTop: element.getBoundingClientRect().top, + inset: inset(), + }), + scroller.scrollHeight, + scroller.clientHeight, + ); + }; + + const capture = () => { + if (scroller === null) return; + const view = scroller.getBoundingClientRect(); + anchorTop = scroller.scrollTop; + anchor = captureAnchor(view, registry.ids(), (id) => + registry.get(id)?.getBoundingClientRect(), + ); + }; + + /** Content moved under the glide in flight: move the view and the glide's + * start with it, so the glide keeps its path and its target. */ + const rebase = () => { + if (scroller === null) return; + const shift = anchorShift( + anchor, + { + viewportTop: scroller.getBoundingClientRect().top, + scrollTop: scroller.scrollTop, + capturedTop: anchorTop, + }, + (id) => registry.get(id)?.getBoundingClientRect().top, + ); + if (Math.abs(shift) <= 0.5) return; + const next = rebaseGlide({ + start: glideStart, + capturedTop: anchorTop, + shift, + }); + glideStart = next.start; + write(next.scrollTop); + capture(); + }; + + const restore = () => { + if (scroller === null) return; + if (gliding) { + rebase(); + return; + } + if (following) { + write(endTop()); + return; + } + const correction = anchorCorrection( + anchor, + scroller.getBoundingClientRect().top, + (id) => registry.get(id)?.getBoundingClientRect().top, + ); + if (Math.abs(correction) > 0.5) write(scroller.scrollTop + correction); + }; + + const updateReserve = () => { + if (scroller === null || spacer === null) return; + let next: number; + const element = settledId === null ? undefined : registry.get(settledId); + if (policy === "settle" && element !== undefined) { + const view = scroller.getBoundingClientRect(); + const offset = scroller.scrollTop - view.top; + const toScroll = (top: number) => top + offset; + next = reservedSpace({ + viewportHeight: scroller.clientHeight - inset(), + itemTop: toScroll(element.getBoundingClientRect().top), + contentEnd: toScroll(spacer.getBoundingClientRect().top), + }); + } else { + // The settled item was removed or the policy changed: release the + // reserve, keeping only what holds the view where it is. + settledId = null; + next = releasedReserve({ + scrollTop: scroller.scrollTop, + distanceFromEnd: distance(), + }); + } + if (Math.abs(next - reserved) <= 0.5) return; + reserved = next; + spacer.style.height = `${next}px`; + }; + + const notifyScroll = () => { + const byReader = scrolledByReader; + scrolled = false; + scrolledByReader = false; + if (scroller === null) return; + const scrollTop = scroller.scrollTop; + const previousTop = notifiedTop; + notifiedTop = scrollTop; + if (scrollListeners.size === 0) return; + const snapshot: ConversationScrollSnapshot = { + scrollTop, + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + distanceFromEnd: distance(), + delta: previousTop === null ? 0 : scrollTop - previousTop, + byReader, + }; + notifyAll(scrollListeners, (listener) => listener(snapshot)); + }; + + /** The one per-frame pass over scroll-derived state: `atBottom`, and the + * snapshot owed to scroll listeners. */ + const scheduleAtBottom = () => { + if (frame !== 0) return; + frame = requestAnimationFrame(() => { + frame = 0; + if (scrolled) notifyScroll(); + const next = distance() <= CONVERSATION_END_THRESHOLD; + if (next === atBottom) return; + atBottom = next; + notifyAll(atBottomListeners, (listener) => listener()); + }); + }; + + const endGlide = (arrived: boolean) => { + const onEnd = glideEnd; + glideEnd = undefined; + onEnd?.(arrived); + }; + + const cancelGlide = () => { + glideToken += 1; + gliding = false; + endGlide(false); + }; + + /** One eased motion towards a target measured afresh every frame; instant + * under reduced motion. The reader's input or scroll, or another glide, + * cancels it. Content that moves above the view mid-glide carries the glide + * with it (`rebase`). `onEnd` hears whether it arrived. */ + const glide = ( + target: () => number | null, + onEnd?: (arrived: boolean) => void, + ) => { + if (scroller === null) { + onEnd?.(false); + return; + } + cancelGlide(); + const token = glideToken; + const first = target(); + if (first === null) { + onEnd?.(false); + return; + } + gliding = true; + glideEnd = onEnd; + const arrive = () => { + if (token !== glideToken) return; + gliding = false; + if (policy === "stick") { + following = distance() <= CONVERSATION_END_THRESHOLD; + } + capture(); + scheduleAtBottom(); + endGlide(true); + }; + const start = scroller.scrollTop; + if (reducedMotion || Math.abs(first - start) <= 1) { + write(first); + arrive(); + return; + } + glideStart = start; + capture(); + const duration = glideDuration(first - start); + const begin = performance.now(); + const step = (now: number) => { + if (token !== glideToken) return; + // Content may have moved since the last frame with no resize callback + // yet (this frame's runs after it). + rebase(); + const progress = Math.min((now - begin) / duration, 1); + const aim = target(); + // The item went away. Waiting out the timer would still report + // arrival, and the view never moved. + if (aim === null) { + gliding = false; + endGlide(false); + return; + } + write(glidePosition(glideStart, aim, progress)); + capture(); + if (progress < 1) requestAnimationFrame(step); + else arrive(); + }; + requestAnimationFrame(step); + }; + + const openGesture = () => { + gestureUntil = performance.now() + CONVERSATION_GESTURE_QUIET_MS; + cancelGlide(); + }; + + const onScroll = () => { + if (scroller === null) return; + const now = performance.now(); + const cause = classifyScroll({ + scrollTop: scroller.scrollTop, + written, + now, + gestureUntil: + pointerHeld || touchHeld ? Number.POSITIVE_INFINITY : gestureUntil, + }); + // This event reports where the view is now: any write before it has + // either landed or been overtaken. + written = null; + scrolled = true; + if (cause === "reader") scrolledByReader = true; + scheduleAtBottom(); + if (gliding) { + // The reader's scroll (momentum already running when the glide began) + // stops the glide, as their input does; any other leaves it in charge. + if (cause !== "reader") return; + cancelGlide(); + } + if (cause === "reader") { + gestureUntil = extendGesture({ now, gestureUntil, momentumUntil }); + const atEnd = distance() <= CONVERSATION_END_THRESHOLD; + if (policy === "stick") following = atEnd; + else if (!atEnd) following = false; + capture(); + return; + } + if (cause === "self") { + capture(); + return; + } + // The layout moved the view. Following, the end wins over any scroll + // that stays at the end. A scroll right after a resize that lands at the + // last reachable position is the resize's clamp (with the browser's own + // anchoring off, a resize moves the view no other way), already handled + // by the resize callback, and must not become the anchor. Any other is + // the page moving the view on purpose (find in page, focus), even + // mid-stream, and is kept: away from the end, it breaks a follow as the + // reader's own scroll would. + if (following) { + if (distance() <= CONVERSATION_END_THRESHOLD) { + write(endTop()); + return; + } + following = false; + } + const clamped = + now - resizedAt <= 100 && + scroller.scrollTop >= scroller.scrollHeight - scroller.clientHeight - 1; + if (!clamped) capture(); + }; + + const onResize = () => { + resizedAt = performance.now(); + updateReserve(); + restore(); + scheduleAtBottom(); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (isScrollKey(event)) openGesture(); + }; + + const onPointerDown = (event: PointerEvent) => { + // Only the container itself (its scrollbar): a click on a control inside + // an item is not a scroll. + if (event.target !== scroller) return; + pointerHeld = true; + openGesture(); + }; + + const onPointerUp = () => { + if (!pointerHeld) return; + pointerHeld = false; + gestureUntil = performance.now() + CONVERSATION_GESTURE_QUIET_MS; + }; + + const onTouchStart = () => { + touchHeld = true; + openGesture(); + }; + + const onTouchEnd = () => { + if (!touchHeld) return; + touchHeld = false; + const now = performance.now(); + gestureUntil = now + CONVERSATION_GESTURE_QUIET_MS; + momentumUntil = now + CONVERSATION_MOMENTUM_MS; + }; + + const onWheel = () => { + openGesture(); + momentumUntil = performance.now() + CONVERSATION_MOMENTUM_MS; + }; + + return { + items, + registry, + attach(element: HTMLElement, content: HTMLElement, reserve: HTMLElement) { + scroller = element; + spacer = reserve; + // A new spacer starts empty, whatever the last one held. + reserved = 0; + const observer = new ResizeObserver(onResize); + observer.observe(content); + observer.observe(element); + const stopListening = registry.subscribe(updateReserve); + element.addEventListener("scroll", onScroll, { passive: true }); + element.addEventListener("wheel", onWheel, { passive: true }); + element.addEventListener("touchstart", onTouchStart, { passive: true }); + element.addEventListener("touchmove", openGesture, { passive: true }); + element.addEventListener("touchend", onTouchEnd, { passive: true }); + element.addEventListener("touchcancel", onTouchEnd, { passive: true }); + element.addEventListener("keydown", onKeyDown); + element.addEventListener("pointerdown", onPointerDown); + window.addEventListener("pointerup", onPointerUp); + window.addEventListener("pointercancel", onPointerUp); + updateReserve(); + // A remounted log opens where the last one left the reader: at the end + // while following, otherwise with the anchored items where they were. + restore(); + capture(); + notifiedTop = null; + scrolled = true; + scheduleAtBottom(); + return () => { + observer.disconnect(); + stopListening(); + element.removeEventListener("scroll", onScroll); + element.removeEventListener("wheel", onWheel); + element.removeEventListener("touchstart", onTouchStart); + element.removeEventListener("touchmove", openGesture); + element.removeEventListener("touchend", onTouchEnd); + element.removeEventListener("touchcancel", onTouchEnd); + element.removeEventListener("keydown", onKeyDown); + element.removeEventListener("pointerdown", onPointerDown); + window.removeEventListener("pointerup", onPointerUp); + window.removeEventListener("pointercancel", onPointerUp); + cancelAnimationFrame(frame); + frame = 0; + cancelGlide(); + pointerHeld = false; + touchHeld = false; + momentumUntil = 0; + scrolled = false; + scrolledByReader = false; + scroller = null; + spacer = null; + }; + }, + setPolicy(next: ConversationScrollPolicy) { + if (next === policy) return; + policy = next; + if (policy === "stick") + following = distance() <= CONVERSATION_END_THRESHOLD; + updateReserve(); + }, + setReducedMotion(next: boolean) { + reducedMotion = next; + }, + /** The host's latest sent item. The first value seen is history, not a + * send; each change after it to a registered item's id is one. */ + sent(id: string | undefined) { + if (!sentSeeded) { + sentSeeded = true; + lastSent = id; + return; + } + if (id === lastSent) return; + lastSent = id; + if (id === undefined || registry.get(id) === undefined) return; + if (policy === "stick") { + following = true; + glide(endTop); + return; + } + following = false; + settledId = id; + // Reserve first, so the target is clamped against the room made for it. + updateReserve(); + glide(() => itemTarget(id)); + }, + scrollToItem(id: string, options?: ConversationScrollToItemOptions) { + // No such item, no jump: the follow and any glide in flight carry on. + if (registry.get(id) === undefined) { + options?.onEnd?.(false); + return; + } + following = false; + glide(() => itemTarget(id), options?.onEnd); + }, + scrollToEnd() { + glide(endTop); + }, + captureAnchor: capture, + restoreAnchor: restore, + focus() { + scroller?.focus({ preventScroll: true }); + }, + subscribeAtBottom(listener: () => void) { + atBottomListeners.add(listener); + return () => { + atBottomListeners.delete(listener); + }; + }, + getAtBottom: () => atBottom, + scrollContainer: () => scroller, + subscribeScroll(listener: (snapshot: ConversationScrollSnapshot) => void) { + scrollListeners.add(listener); + return () => { + scrollListeners.delete(listener); + }; + }, + }; +} + +type ConversationEngine = ReturnType; + +const ConversationContext = createContext(null); + +function useEngine(component: string): ConversationEngine { + const engine = useContext(ConversationContext); + if (engine === null) { + throw new Error(`${component} must be used within `); + } + return engine; +} + +function useMergedRef(own: RefCallback, theirs: Ref | undefined) { + return useCallback( + (node: T | null) => { + const cleanup = own(node); + if (typeof theirs === "function") theirs(node); + else if (theirs !== null && theirs !== undefined) theirs.current = node; + return () => { + if (typeof cleanup === "function") cleanup(); + if (typeof theirs === "function") theirs(null); + else if (theirs !== null && theirs !== undefined) theirs.current = null; + }; + }, + [own, theirs], + ); +} + +/** + * The scroll state shared under a `Conversation`. The functions and `items` + * keep their identity for the conversation's lifetime; `atBottom` is the only + * value that changes, and a component calling this re-renders only when it + * flips, never per scroll. + * + * For per-scroll work without re-rendering (a reading position, a history + * window that loads near the top and holds still while the reader is up): + * + * - `scrollContainer()` returns the scroll container element (role `log`, + * whatever its `data-slot` or `ref`), or `null` while none is mounted. + * - `subscribeScroll(listener)` returns an unsubscribe function. The listener + * gets a `ConversationScrollSnapshot` at most once per animation frame, + * from the same frame pass that updates `atBottom` (no listener of its own + * on the container): once after the container mounts, then after each + * frame with scroll events. `byReader` says whether any of those scrolls + * was the reader's own rather than the conversation's or the layout's; + * `delta` is the change in `scrollTop` since the previous snapshot. + */ +export function useConversation(): ConversationContextValue { + const engine = useEngine("useConversation"); + const atBottom = useSyncExternalStore( + engine.subscribeAtBottom, + engine.getAtBottom, + () => true, + ); + return useMemo( + () => ({ + atBottom, + items: engine.items, + scrollToItem: engine.scrollToItem, + scrollToEnd: engine.scrollToEnd, + captureAnchor: engine.captureAnchor, + restoreAnchor: engine.restoreAnchor, + scrollContainer: engine.scrollContainer, + subscribeScroll: engine.subscribeScroll, + }), + [engine, atBottom], + ); +} + +export type ConversationProps = ComponentProps<"div"> & { + /** How the view moves as the conversation grows. Defaults to `settle`. */ + readonly scrollPolicy?: ConversationScrollPolicy; + /** + * The id of the reader's latest sent item. Each change to the `itemId` of a + * mounted `ConversationItem` is a send: under `settle` that item glides to + * the top of the view, under `stick` the view glides to the end and follows + * again. Render the item in the same update that sets its id: an id with no + * item changes nothing, and is not a send when the item appears later. The + * value at mount is history. + */ + readonly sentId?: string | undefined; +}; + +/** + * Owns a conversation's scrolling and shares it through `useConversation()`. + * It renders no messages: put a `ConversationContent` inside, with a + * `ConversationItem` around each thing the host renders, and a + * `ConversationScrollButton` beside it. + * + * Only the reader's own scrolling (wheel, touch, scroll keys, the scrollbar), + * or the page moving the view away from the end (focus, find in page), breaks + * a follow; content resizing never scrolls what they are reading. Jumps are + * one glide, instant under reduced motion. + */ +export function Conversation({ + scrollPolicy = "settle", + sentId, + className, + children, + ...props +}: ConversationProps) { + const [engine] = useState(createConversationEngine); + const reducedMotion = usePrefersReducedMotion(); + + useLayoutEffect(() => { + engine.setReducedMotion(reducedMotion); + }, [engine, reducedMotion]); + useLayoutEffect(() => { + engine.setPolicy(scrollPolicy); + }, [engine, scrollPolicy]); + useLayoutEffect(() => { + engine.sent(sentId); + }, [engine, sentId]); + + return ( + +
+ {children} +
+
+ ); +} + +export type ConversationContentProps = ComponentProps<"div">; + +/** + * The scrolling log, and the part's root: `ref`, `id`, `className`, the ARIA + * naming props and every other prop go to the scroll container (role `log`). + * The log is named "Conversation" unless the host passes `aria-label` or + * `aria-labelledby`. + * + * Children render in a content column inside it (`data-slot` + * `conversation-column`, `flex flex-col gap-4 p-4`). Restyle the column from + * this part's `className` through its slot, e.g. + * `[&>[data-slot=conversation-column]]:gap-6`. While the column holds a + * `ConversationEmptyState`, it fills the view so the empty state centres. + * + * The container is focusable so the keyboard can scroll it, turns off the + * browser's scroll anchoring (the conversation anchors for itself), and keeps + * an empty block after the content: the room a settled item needs to reach + * the top of the view. + */ +export const ConversationContent = forwardRef< + HTMLDivElement, + ConversationContentProps +>(function ConversationContent( + { + "aria-label": label, + "aria-labelledby": labelledBy, + className, + children, + ...props + }, + ref, +) { + const engine = useEngine("ConversationContent"); + const scrollerRef = useRef(null); + const columnRef = useRef(null); + const spacerRef = useRef(null); + const ownScroller = useCallback((node: HTMLDivElement | null) => { + scrollerRef.current = node; + return () => { + scrollerRef.current = null; + }; + }, []); + const mergedRef = useMergedRef(ownScroller, ref); + + useLayoutEffect(() => { + const scroller = scrollerRef.current; + const column = columnRef.current; + const spacer = spacerRef.current; + if (scroller === null || column === null || spacer === null) return; + return engine.attach(scroller, column, spacer); + }, [engine]); + + return ( +
+
+ {children} +
+
+
+ ); +}); + +export type ConversationItemProps = ComponentProps<"div"> & { + /** The registry key: what `scrollToItem`, `sentId` and anchors refer to. */ + readonly itemId: string; +}; + +/** + * One entry in the conversation, registered by `itemId` so the conversation + * can find it, settle it and anchor on it. Wrap whatever the host renders. + */ +export const ConversationItem = forwardRef< + HTMLDivElement, + ConversationItemProps +>(function ConversationItem({ itemId, ...props }, ref) { + const engine = useEngine("ConversationItem"); + // React 19 runs the cleanup `register` returns. React 18 ignores that + // return and detaches by calling the ref with null, so release there too. + const release = useRef<(() => void) | null>(null); + const register = useCallback( + (element: HTMLDivElement | null) => { + release.current?.(); + release.current = null; + if (element === null) return; + const cleanup = engine.registry.register(itemId, element); + release.current = cleanup; + return cleanup; + }, + [engine, itemId], + ); + const mergedRef = useMergedRef(register, ref); + return ( +
+ ); +}); + +export type ConversationScrollButtonProps = ComponentProps<"button">; + +/** + * Appears when the end of the conversation is out of view and glides back to + * it. Focus moves to the conversation as it does, and whenever the button + * leaves while it holds focus, however the end came into view. + */ +export const ConversationScrollButton = forwardRef< + HTMLButtonElement, + ConversationScrollButtonProps +>(function ConversationScrollButton(props, ref) { + const { atBottom } = useConversation(); + return atBottom ? null : ; +}); + +/** The button itself, mounted only while the end is out of view. */ +const ScrollToLatest = forwardRef< + HTMLButtonElement, + ConversationScrollButtonProps +>(function ScrollToLatest( + { + "aria-label": label = "Scroll to latest", + className, + children, + onClick, + ...props + }, + ref, +) { + const engine = useEngine("ConversationScrollButton"); + const buttonRef = useRef(null); + const ownButton = useCallback((node: HTMLButtonElement | null) => { + buttonRef.current = node; + }, []); + const mergedRef = useMergedRef(ownButton, ref); + // A layout effect's cleanup runs while the leaving button is still in the + // document, so it can tell whether it held focus. + useLayoutEffect(() => { + const button = buttonRef.current; + return () => { + if (button !== null && button === document.activeElement) engine.focus(); + }; + }, [engine]); + return ( + + ); +}); + +export type ConversationEmptyStateProps = ComponentProps<"div"> & { + readonly title?: string; + readonly description?: ReactNode; + readonly icon?: ReactNode; +}; + +/** + * No items yet. Children replace the default title, description and icon. + * Render it inside `ConversationContent`, where it centres in the view. + */ +export function ConversationEmptyState({ + title = "No messages yet", + description, + icon, + className, + children, + ...props +}: ConversationEmptyStateProps) { + return ( +
+ {children ?? ( + + )} +
+ ); +} diff --git a/stories/chat/conversation.stories.tsx b/stories/chat/conversation.stories.tsx new file mode 100644 index 00000000..04245fe5 --- /dev/null +++ b/stories/chat/conversation.stories.tsx @@ -0,0 +1,214 @@ +import { MessageSquare, Pin } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; + +import { Button } from "../../src/ui/button.js"; +import { + Conversation, + ConversationContent, + ConversationEmptyState, + ConversationItem, + ConversationScrollButton, + type ConversationScrollPolicy, +} from "../../src/ui/conversation.js"; +import { + ConversationDivider, + ConversationNotice, +} from "../../src/ui/conversation-divider.js"; + +export default { title: "Chat / Conversation" }; + +type Row = { + readonly id: string; + readonly from: "reader" | "agent"; + readonly text: string; +}; + +const QUESTIONS = [ + "What changed in the last run?", + "Can you summarise the open items?", + "Which step took the longest?", + "Draft a note for the team about it.", +]; + +const ANSWERS = [ + "Two steps were added and one was retried after a timeout. The rest ran as before.", + "Three items are open: the export review, a failing check on the staging workflow, and a question about access.", + "The fetch step took most of the time, about forty seconds, because it paged through every result before filtering.", + "Here is a short note you can send. It covers what changed, why, and what to watch for on the next run.", +]; + +const REPLY = + "Here is what I found. The workflow ran four steps; the second waited on an approval for most of its time, and the last wrote its output to the shared folder. Nothing failed, though one call was retried once after a slow response. If you want, I can schedule the next run earlier so the approval lands before the end of the day, or split the long step into two so the wait is easier to see."; + +function history(count: number): Row[] { + return Array.from({ length: count }, (_, index) => { + const turn = Math.floor(index / 2); + const from = index % 2 === 0 ? "reader" : "agent"; + const pool = from === "reader" ? QUESTIONS : ANSWERS; + return { + id: `m${index}`, + from, + text: pool[turn % pool.length] ?? "", + }; + }); +} + +/** A placeholder row: the conversation renders whatever the host gives it. */ +function Bubble({ row }: { readonly row: Row }) { + return row.from === "reader" ? ( +

+ {row.text} +

+ ) : ( +

{row.text}

+ ); +} + +function Frame({ children }: { readonly children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +/** A conversation that sends and streams placeholder replies. */ +function Live({ + policy, + initial, + streamOnMount = false, +}: { + readonly policy: ConversationScrollPolicy; + readonly initial: number; + readonly streamOnMount?: boolean; +}) { + const [rows, setRows] = useState(() => history(initial)); + const [sentId, setSentId] = useState(); + const sends = useRef(0); + const timer = useRef | undefined>(undefined); + + const send = () => { + clearInterval(timer.current); + sends.current += 1; + const asked = `sent-${sends.current}`; + const reply = `reply-${sends.current}`; + const words = REPLY.split(" "); + setRows((previous) => [ + ...previous, + { + id: asked, + from: "reader", + text: QUESTIONS[sends.current % QUESTIONS.length] ?? "", + }, + { id: reply, from: "agent", text: "" }, + ]); + setSentId(asked); + let written = 0; + timer.current = setInterval(() => { + written += 3; + setRows((previous) => + previous.map((row) => + row.id === reply + ? { ...row, text: words.slice(0, written).join(" ") } + : row, + ), + ); + if (written >= words.length) clearInterval(timer.current); + }, 120); + }; + + useEffect(() => { + // A send after mount, so the first reply streams as the story opens. + const start = streamOnMount ? setTimeout(send, 300) : undefined; + return () => { + clearTimeout(start); + clearInterval(timer.current); + }; + // `send` only touches state setters and refs, so the first render's is current. + }, [streamOnMount]); + + return ( + + + + {rows.length === 0 ? ( + } + description="Send a message to start." + /> + ) : ( + rows.map((row) => ( + + + + )) + )} + + + +
+ +
+ + ); +} + +/** A few turns, shorter than the view. */ +export const Short = () => ; + +/** A long history, opened at the latest; scroll up for the jump button. */ +export const Long = () => ; + +/** `settle`: the sent message glides to the top and the reply writes in below + * it; the view never moves while it streams. */ +export const StreamingSettle = () => ( + +); + +/** `stick`: the view follows the reply while you are at the end; scroll up + * and it stays put. */ +export const StreamingStick = () => ( + +); + +/** No messages yet. */ +export const Empty = () => ; + +/** Day dividers and one-line system notices between messages. */ +export const DividersAndNotices = () => { + const rows = history(6); + return ( + + + + Yesterday + {rows.slice(0, 2).map((row) => ( + + + + ))} + A scheduled run started + {rows.slice(2, 4).map((row) => ( + + + + ))} + Today + }> + The agent pinned a message + + {rows.slice(4).map((row) => ( + + + + ))} + + + + + + ); +};