From b124f8fe13aa91c64ffaceb3f47472635cf3b958 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 21 Oct 2026 23:43:19 +0530 Subject: [PATCH 01/12] feat(conversation): add Conversation and its scroll anchoring Conversation owns the scroll container so every chat surface scrolls the same way. Scroll policy is a prop: settle lands a sent message at the top and never moves while text streams; stick follows the end while the reader is there. Layout shifts never scroll, and only the reader's own input breaks a follow. The anchor algorithm and settle geometry are pure functions, and an anchor correction also applies mid-glide so a glide lands where it meant to. useConversation() shares an item registry, scrollToItem, scrollToEnd, captureAnchor/restoreAnchor, the scroll container and a per-frame scroll snapshot that tells the reader's scrolls from the engine's. The context value keeps its identity across scrolls. --- e2e/conversation.spec.ts | 307 +++++++++ e2e/fixtures.tsx | 260 ++++++- src/index.ts | 2 + src/lib/conversation-scroll.test.ts | 482 +++++++++++++ src/lib/conversation-scroll.ts | 309 +++++++++ src/ui/conversation-divider.tsx | 72 ++ src/ui/conversation.test.tsx | 635 +++++++++++++++++ src/ui/conversation.tsx | 948 ++++++++++++++++++++++++++ stories/chat/conversation.stories.tsx | 214 ++++++ 9 files changed, 3228 insertions(+), 1 deletion(-) create mode 100644 e2e/conversation.spec.ts create mode 100644 src/lib/conversation-scroll.test.ts create mode 100644 src/lib/conversation-scroll.ts create mode 100644 src/ui/conversation-divider.tsx create mode 100644 src/ui/conversation.test.tsx create mode 100644 src/ui/conversation.tsx create mode 100644 stories/chat/conversation.stories.tsx diff --git a/e2e/conversation.spec.ts b/e2e/conversation.spec.ts new file mode 100644 index 00000000..b4782961 --- /dev/null +++ b/e2e/conversation.spec.ts @@ -0,0 +1,307 @@ +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("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); + }); +}); + +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); + }); +}); diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index c343ae2f..1aac008f 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,10 @@ import { AuthLayout, Button, ChatThread, + Conversation, + ConversationContent, + ConversationItem, + ConversationScrollButton, DictationStatusLine, FilterChip, Select, @@ -15,6 +19,8 @@ import { ThinkingMark, type ApprovalRequest, type ChatMessage, + type ConversationScrollPolicy, + useConversation, } from "@corbits/react-ui"; import { CommandPalette, @@ -172,6 +178,255 @@ 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, +}: { + onSend: () => void; + onWrite: () => void; + onEarlier: () => void; + onTrim: () => 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); + 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))} + /> +
+
+ ); +} + +/** 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))} + /> +
+
+ ); +} + export const scenarios: Record ReactNode> = { smoke: () => , "approval-card": () => , @@ -201,4 +456,7 @@ export const scenarios: Record ReactNode> = { ), + "conversation-settle": () => , + "conversation-stick": () => , + "conversation-glide": () => , }; 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..63b5c2a3 --- /dev/null +++ b/src/lib/conversation-scroll.test.ts @@ -0,0 +1,482 @@ +import { describe, expect, test } from "bun:test"; + +import { + anchorCorrection, + anchorShift, + captureAnchor, + clampScrollTop, + classifyScroll, + distanceFromEnd, + extendGesture, + glideDuration, + glidePosition, + rebaseGlide, + 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("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..0dc12c03 --- /dev/null +++ b/src/lib/conversation-scroll.ts @@ -0,0 +1,309 @@ +/** + * 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)); +} + +/** 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 (content shrinking under the view, an image loading) is the + * layout's, and must neither break a follow nor be captured as the reader's + * position. + */ +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..8da865fd --- /dev/null +++ b/src/ui/conversation-divider.tsx @@ -0,0 +1,72 @@ +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 it is a plain + * separator; with one, the label is ordinary text so it is read in place. + */ +export function ConversationDivider({ + className, + children, + ...props +}: ConversationDividerProps) { + const labelled = children !== undefined && children !== null; + return ( +
+ + {labelled ? ( + <> + {children} + + + ) : null} +
+ ); +} + +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..0f6d6d22 --- /dev/null +++ b/src/ui/conversation.test.tsx @@ -0,0 +1,635 @@ +import { act, memo, useEffect } from "react"; +import type { ReactElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { describe, expect, test } from "bun:test"; + +import { + Conversation, + ConversationContent, + ConversationItem, + ConversationScrollButton, + useConversation, + type ConversationContextValue, + type ConversationScrollSnapshot, +} from "./conversation.js"; +import { + ConversationDivider, + ConversationNotice, +} from "./conversation-divider.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, + }); +} + +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(); + }); +}); + +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(); + }); +}); + +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("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", () => { + function mountPolicy(policy: "settle" | "stick") { + let api: ConversationContextValue | null = null; + function Probe() { + api = useConversation(); + return null; + } + const mounted = render( + + + a + + + , + ); + const current = () => { + if (api === null) throw new Error("expected the conversation API"); + return api; + }; + return { mounted, element: scroller(mounted.container), current }; + } + + 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")); + }; + + 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(); + }); + + /** 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")); + }; + + 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 () => { + 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: performance.now(), byReader: snapshot.byReader }), + ); + await nextFrame(); + + flick(element, 9_000); + const lifted = performance.now(); + let top = 9_000; + while (performance.now() - lifted < 2_600) { + await sleep(100); + top -= 10; + element.scrollTop = top; + element.dispatchEvent(new Event("scroll")); + await nextFrame(); + } + const early = seen.filter(({ at }) => at > lifted && at - lifted < 1_800); + const late = seen.filter(({ at }) => at - lifted > 2_200); + expect(early.length).toBeGreaterThan(10); + expect(early.every(({ byReader }) => byReader)).toBe(true); + expect(late.length).toBeGreaterThan(0); + expect(late.every(({ byReader }) => !byReader)).toBe(true); + mounted.unmount(); + }); + + 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("ConversationDivider and ConversationNotice", () => { + test("a bare divider is a separator; 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?.getAttribute("role")).toBe("separator"); + expect(day?.getAttribute("role")).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..26451b88 --- /dev/null +++ b/src/ui/conversation.tsx @@ -0,0 +1,948 @@ +"use client"; + +import { ArrowDown } from "lucide-react"; +import { + createContext, + 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, + 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 moving the view. */ + readonly byReader: boolean; +}; + +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. */ + readonly scrollToItem: (id: string) => 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; + }); + } + } +} + +function isEditable(target: EventTarget | null): boolean { + return ( + target instanceof HTMLElement && + (target.isContentEditable || target.matches("input, textarea, select")) + ); +} + +/** + * 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; + /** 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 = 0; + 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: nothing to hold. + settledId = null; + } + 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 cancelGlide = () => { + glideToken += 1; + gliding = false; + }; + + /** One eased motion towards a target measured afresh every frame; instant + * under reduced motion. Reader input or another glide cancels it. Content + * that moves above the view mid-glide carries the glide with it (`rebase`). */ + const glide = (target: () => number | null) => { + if (scroller === null) return; + const token = glideToken + 1; + glideToken = token; + const first = target(); + if (first === null) { + gliding = false; + return; + } + gliding = true; + const arrive = () => { + if (token !== glideToken) return; + gliding = false; + if (policy === "stick") { + following = distance() <= CONVERSATION_END_THRESHOLD; + } + capture(); + scheduleAtBottom(); + }; + 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(); + if (aim !== null) 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) return; + 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. Otherwise a scroll + // right after a resize is the resize's clamp, already corrected by the + // resize callback, and must not become the anchor; any other is the page + // moving the view on purpose (find in page, focus) and is kept. + if (following) { + write(endTop()); + return; + } + if (now - resizedAt > 100) capture(); + }; + + const onResize = () => { + resizedAt = performance.now(); + updateReserve(); + restore(); + scheduleAtBottom(); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (!isEditable(event.target)) 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; + 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); + if (following) write(endTop()); + 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 is one. */ + sent(id: string | undefined) { + if (!sentSeeded) { + sentSeeded = true; + lastSent = id; + return; + } + if (id === lastSent) return; + lastSent = id; + if (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) { + following = false; + glide(() => itemTarget(id)); + }, + 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 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. 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 input (wheel, touch, keys, the scrollbar) 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`. + * + * 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 function ConversationContent({ + "aria-label": label, + "aria-labelledby": labelledBy, + className, + children, + ref, + ...props +}: ConversationContentProps) { + 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 function ConversationItem({ + itemId, + ref, + ...props +}: ConversationItemProps) { + const engine = useEngine("ConversationItem"); + const register = useCallback( + (element: HTMLDivElement | null) => + element === null ? undefined : engine.registry.register(itemId, element), + [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, since the button leaves. + */ +export function ConversationScrollButton({ + "aria-label": label = "Scroll to latest", + className, + children, + onClick, + ...props +}: ConversationScrollButtonProps) { + const engine = useEngine("ConversationScrollButton"); + const { atBottom, scrollToEnd } = useConversation(); + if (atBottom) return null; + 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. */ +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) => ( + + + + ))} + + + + + + ); +}; From 0a6ea5c606a8f5ec78cd0eb407b2d6cffcf5e8fc Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 22 Oct 2026 20:01:37 +0530 Subject: [PATCH 02/12] fix(conversation): release a ConversationItem when React 18 detaches it React 18 ignores a cleanup returned from a ref and calls the ref with null, so an unmounted item stayed registered and scroll anchoring measured a detached node. --- src/ui/conversation.test.tsx | 41 ++++++++++++++++++++++++++++++++++++ src/ui/conversation.tsx | 13 ++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/ui/conversation.test.tsx b/src/ui/conversation.test.tsx index 0f6d6d22..9c8ff79e 100644 --- a/src/ui/conversation.test.tsx +++ b/src/ui/conversation.test.tsx @@ -183,6 +183,47 @@ describe("ConversationItem registry", () => { 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", () => { diff --git a/src/ui/conversation.tsx b/src/ui/conversation.tsx index 26451b88..5df51681 100644 --- a/src/ui/conversation.tsx +++ b/src/ui/conversation.tsx @@ -862,9 +862,18 @@ export function ConversationItem({ ...props }: ConversationItemProps) { 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) => - element === null ? undefined : engine.registry.register(itemId, element), + (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); From 64f8b7a0e8f569984aa86b815b29f2dfb7ea72ad Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Tue, 27 Oct 2026 19:25:07 +0545 Subject: [PATCH 03/12] fix(conversation): keep page scrolls and settle glide precedence A scroll that came with no reader input was taken for a layout shift while the view followed the end, and reversed. Moving focus to an earlier item, or find in page, snapped straight back. Such a scroll that leaves the end outside the moment after a resize is now kept and breaks the follow; the engine's own writes are still told apart by position. A reader scroll during a glide now stops the glide, so momentum that was already running wins and stays the reader's, instead of being reported as theirs and then overwritten. The reserve after a send is re-synced when the log remounts, so the end is the content's end again, and when the settled item leaves the reserve keeps what the view needs instead of collapsing and moving it. A sentId with no rendered item no longer glides, collapses the reserve or breaks the follow. Only scroll keys open the reader's gesture, and the scroll button hands focus to the log when it leaves while focused. --- e2e/conversation.spec.ts | 73 ++++++ e2e/fixtures.tsx | 13 +- src/lib/conversation-scroll.test.ts | 20 ++ src/lib/conversation-scroll.ts | 25 +- src/ui/conversation.test.tsx | 375 +++++++++++++++++++++++++--- src/ui/conversation.tsx | 111 ++++++-- 6 files changed, 546 insertions(+), 71 deletions(-) diff --git a/e2e/conversation.spec.ts b/e2e/conversation.spec.ts index b4782961..15f89520 100644 --- a/e2e/conversation.spec.ts +++ b/e2e/conversation.spec.ts @@ -153,6 +153,51 @@ test.describe("settle", () => { ).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 reserve, so the end is the content's end", 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); + + await page.getByRole("button", { name: "Remount" }).click(); + await write(page, 0); + 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, }) => { @@ -211,6 +256,34 @@ test.describe("stick", () => { }); }); +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}`); + // Past the moment after the history's resize, when a scroll is still + // taken for the resize's clamp. + 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). */ diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index 1aac008f..4c9fc5c2 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -198,11 +198,15 @@ function ConversationControls({ 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); @@ -223,6 +227,8 @@ function ConversationControls({ Load earlier + +
); } @@ -237,6 +243,7 @@ function ConversationScenario({ 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); @@ -251,7 +258,7 @@ function ConversationScenario({ }} > - + {rows.map((row) => (

@@ -289,6 +296,10 @@ function ConversationScenario({ ]); }} onTrim={() => setRows((previous) => previous.slice(5))} + onUnsend={() => + setRows((previous) => previous.filter((row) => row.id !== sentId)) + } + onRemount={() => setMounts(mounts + 1)} />

diff --git a/src/lib/conversation-scroll.test.ts b/src/lib/conversation-scroll.test.ts index 63b5c2a3..0dde98cb 100644 --- a/src/lib/conversation-scroll.test.ts +++ b/src/lib/conversation-scroll.test.ts @@ -11,6 +11,7 @@ import { glideDuration, glidePosition, rebaseGlide, + releasedReserve, reservedSpace, settleTarget, type ConversationSpan, @@ -79,6 +80,25 @@ describe("reservedSpace", () => { }); }); +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); diff --git a/src/lib/conversation-scroll.ts b/src/lib/conversation-scroll.ts index 0dc12c03..17154274 100644 --- a/src/lib/conversation-scroll.ts +++ b/src/lib/conversation-scroll.ts @@ -205,6 +205,24 @@ export function reservedSpace({ 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({ @@ -238,9 +256,10 @@ export type ConversationScrollCause = "reader" | "self" | "layout"; * 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 (content shrinking under the view, an image loading) is the - * layout's, and must neither break a follow nor be captured as the reader's - * position. + * 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, diff --git a/src/ui/conversation.test.tsx b/src/ui/conversation.test.tsx index 9c8ff79e..155d2f61 100644 --- a/src/ui/conversation.test.tsx +++ b/src/ui/conversation.test.tsx @@ -6,10 +6,13 @@ import { 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 { @@ -73,6 +76,72 @@ function giveGeometry( }); } +/** 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")); +}; + describe("Conversation context", () => { test("scrolling 100 frames re-renders a memoised child zero times", async () => { let renders = 0; @@ -254,6 +323,34 @@ describe("ConversationScrollButton", () => { 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", () => { @@ -423,37 +520,6 @@ describe("useConversation scroll access", () => { }); describe("reader attribution and scroll listeners", () => { - function mountPolicy(policy: "settle" | "stick") { - let api: ConversationContextValue | null = null; - function Probe() { - api = useConversation(); - return null; - } - const mounted = render( - - - a - - - , - ); - const current = () => { - if (api === null) throw new Error("expected the conversation API"); - return api; - }; - return { mounted, element: scroller(mounted.container), current }; - } - - 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")); - }; - test("the conversation's own follow writes never count as the reader's", async () => { const { mounted, element, current } = mountPolicy("stick"); let scrollHeight = 10_000; @@ -487,15 +553,6 @@ describe("reader attribution and scroll listeners", () => { mounted.unmount(); }); - /** 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")); - }; - 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); @@ -646,6 +703,244 @@ describe("reader attribution and scroll listeners", () => { }); }); +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("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("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 a separator; a labelled one reads its label", () => { const mounted = render( diff --git a/src/ui/conversation.tsx b/src/ui/conversation.tsx index 5df51681..06c4b824 100644 --- a/src/ui/conversation.tsx +++ b/src/ui/conversation.tsx @@ -28,6 +28,7 @@ import { glideDuration, glidePosition, rebaseGlide, + releasedReserve, reservedSpace, settleTarget, type ConversationAnchor, @@ -78,7 +79,8 @@ export type ConversationScrollSnapshot = { * 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 moving the view. */ + * 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; }; @@ -182,6 +184,17 @@ function notifyAll(listeners: ReadonlySet, call: (listener: T) => void) { } } +/** 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 && @@ -344,7 +357,7 @@ function createConversationEngine() { const updateReserve = () => { if (scroller === null || spacer === null) return; - let next = 0; + let next: number; const element = settledId === null ? undefined : registry.get(settledId); if (policy === "settle" && element !== undefined) { const view = scroller.getBoundingClientRect(); @@ -356,8 +369,13 @@ function createConversationEngine() { contentEnd: toScroll(spacer.getBoundingClientRect().top), }); } else { - // The settled item was removed or the policy changed: nothing to hold. + // 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; @@ -404,8 +422,9 @@ function createConversationEngine() { }; /** One eased motion towards a target measured afresh every frame; instant - * under reduced motion. Reader input or another glide cancels it. Content - * that moves above the view mid-glide carries the glide with it (`rebase`). */ + * 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`). */ const glide = (target: () => number | null) => { if (scroller === null) return; const token = glideToken + 1; @@ -471,7 +490,12 @@ function createConversationEngine() { scrolled = true; if (cause === "reader") scrolledByReader = true; scheduleAtBottom(); - if (gliding) return; + 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; @@ -484,15 +508,21 @@ function createConversationEngine() { capture(); return; } - // The layout moved the view. Following, the end wins. Otherwise a scroll - // right after a resize is the resize's clamp, already corrected by the - // resize callback, and must not become the anchor; any other is the page - // moving the view on purpose (find in page, focus) and is kept. + // The layout moved the view. A scroll right after a resize is the + // resize's clamp, already corrected by the resize callback, and must not + // become the anchor; following, the end wins over it, and over any scroll + // that stays at the end. Any other is the page moving the view on purpose + // (find in page, focus) and is kept: away from the end, it breaks a follow + // as the reader's own scroll would. + const afterResize = now - resizedAt <= 100; if (following) { - write(endTop()); - return; + if (afterResize || distance() <= CONVERSATION_END_THRESHOLD) { + write(endTop()); + return; + } + following = false; } - if (now - resizedAt > 100) capture(); + if (!afterResize) capture(); }; const onResize = () => { @@ -503,7 +533,7 @@ function createConversationEngine() { }; const onKeyDown = (event: KeyboardEvent) => { - if (!isEditable(event.target)) openGesture(); + if (SCROLL_KEYS.has(event.key) && !isEditable(event.target)) openGesture(); }; const onPointerDown = (event: PointerEvent) => { @@ -544,6 +574,8 @@ function createConversationEngine() { 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); @@ -558,6 +590,7 @@ function createConversationEngine() { element.addEventListener("pointerdown", onPointerDown); window.addEventListener("pointerup", onPointerUp); window.addEventListener("pointercancel", onPointerUp); + updateReserve(); if (following) write(endTop()); capture(); notifiedTop = null; @@ -599,7 +632,7 @@ function createConversationEngine() { reducedMotion = next; }, /** The host's latest sent item. The first value seen is history, not a - * send; each change after it is one. */ + * send; each change after it to a registered item's id is one. */ sent(id: string | undefined) { if (!sentSeeded) { sentSeeded = true; @@ -608,7 +641,7 @@ function createConversationEngine() { } if (id === lastSent) return; lastSent = id; - if (id === undefined) return; + if (id === undefined || registry.get(id) === undefined) return; if (policy === "stick") { following = true; glide(endTop); @@ -722,9 +755,12 @@ 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 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. The value at mount is history. + * 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; }; @@ -735,9 +771,10 @@ export type ConversationProps = ComponentProps<"div"> & { * `ConversationItem` around each thing the host renders, and a * `ConversationScrollButton` beside it. * - * Only the reader's own input (wheel, touch, keys, the scrollbar) breaks a - * follow; content resizing never scrolls what they are reading. Jumps are one - * glide, instant under reduced motion. + * 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", @@ -891,20 +928,40 @@ 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, since the button leaves. + * 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 function ConversationScrollButton({ +export function ConversationScrollButton(props: ConversationScrollButtonProps) { + const { atBottom } = useConversation(); + return atBottom ? null : ; +} + +/** The button itself, mounted only while the end is out of view. */ +function ScrollToLatest({ "aria-label": label = "Scroll to latest", className, children, onClick, + ref, ...props }: ConversationScrollButtonProps) { const engine = useEngine("ConversationScrollButton"); - const { atBottom, scrollToEnd } = useConversation(); - if (atBottom) return null; + 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; From e585407c6759e9f1e819c6f8f5c9e700f8e749b3 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 19:38:44 +0545 Subject: [PATCH 07/12] fix(conversation): reopen a remounted log where the reader was When ConversationContent mounted again inside the same Conversation, attach wrote the end only while the view followed and otherwise kept the new container's scrollTop of 0. After any send under settle, or once the reader left the end under stick, a remount (a key change, a panel that unmounts its log) opened at the oldest loaded message. attach now restores the held anchor, which the new items answer to under the same ids, and still writes the end while following. --- e2e/conversation.spec.ts | 10 ++++++++- src/ui/conversation.test.tsx | 43 ++++++++++++++++++++++++++++++++++++ src/ui/conversation.tsx | 4 +++- 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/e2e/conversation.spec.ts b/e2e/conversation.spec.ts index 2ca99eda..ec5a7b90 100644 --- a/e2e/conversation.spec.ts +++ b/e2e/conversation.spec.ts @@ -166,7 +166,7 @@ test.describe("settle", () => { ); }); - test("a remounted log keeps the reserve, so the end is the content's end", async ({ + test("a remounted log keeps the reader's place and the reserve", async ({ page, }) => { await open(page, "conversation-settle"); @@ -178,9 +178,17 @@ test.describe("settle", () => { .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(); diff --git a/src/ui/conversation.test.tsx b/src/ui/conversation.test.tsx index 65f1c456..49768d20 100644 --- a/src/ui/conversation.test.tsx +++ b/src/ui/conversation.test.tsx @@ -1072,6 +1072,49 @@ describe("a scroll right after a resize", () => { }); }); +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( diff --git a/src/ui/conversation.tsx b/src/ui/conversation.tsx index d6e59d07..d6e3205c 100644 --- a/src/ui/conversation.tsx +++ b/src/ui/conversation.tsx @@ -638,7 +638,9 @@ function createConversationEngine() { window.addEventListener("pointerup", onPointerUp); window.addEventListener("pointercancel", onPointerUp); updateReserve(); - if (following) write(endTop()); + // 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; From 7437bc54b7e6c990625ed3c2688953423ca4229a Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 19:39:30 +0545 Subject: [PATCH 08/12] fix(conversation): leave the follow alone for an unmounted scrollToItem scrollToItem dropped the follow before the glide found there was no such item, and starting that glide cancelled any glide in flight. A call for an id outside the mounted window moved nothing and reported onEnd(false), yet under stick a streaming reply then ran off the bottom of the view, and a jump already under way stopped short. scrollToItem now reports onEnd(false) for an unmounted id and returns before touching the follow or the glide in flight. --- src/ui/conversation.test.tsx | 35 +++++++++++++++++++++++++++++++++++ src/ui/conversation.tsx | 8 +++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/ui/conversation.test.tsx b/src/ui/conversation.test.tsx index 49768d20..4f803c89 100644 --- a/src/ui/conversation.test.tsx +++ b/src/ui/conversation.test.tsx @@ -977,6 +977,41 @@ describe("scrollToItem's onEnd", () => { expect(ends).toHaveLength(4); 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", () => { diff --git a/src/ui/conversation.tsx b/src/ui/conversation.tsx index d6e3205c..d356f871 100644 --- a/src/ui/conversation.tsx +++ b/src/ui/conversation.tsx @@ -96,7 +96,8 @@ 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. */ + /** 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, @@ -703,6 +704,11 @@ function createConversationEngine() { 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); }, From 70c7ac91c81fa9c9071ca2f59f04649eb9da5c91 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 19:40:11 +0545 Subject: [PATCH 09/12] fix(conversation): centre the empty state inside the log ConversationEmptyState centres with flex-1 and justify-center, but inside ConversationContent its parent is the content column, which is only as tall as its content. With no free space to grow into, the empty state sat at the top of the log. The column now fills the view while it holds an empty state, so the empty state centres in it. --- e2e/conversation.spec.ts | 14 ++++++++++++++ e2e/fixtures.tsx | 22 ++++++++++++++++++++++ src/ui/conversation.tsx | 10 +++++++--- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/e2e/conversation.spec.ts b/e2e/conversation.spec.ts index ec5a7b90..263ef542 100644 --- a/e2e/conversation.spec.ts +++ b/e2e/conversation.spec.ts @@ -385,3 +385,17 @@ test.describe("a glide over content that moves above it", () => { 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 4c9fc5c2..efdddd17 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -8,6 +8,7 @@ import { ChatThread, Conversation, ConversationContent, + ConversationEmptyState, ConversationItem, ConversationScrollButton, DictationStatusLine, @@ -438,6 +439,26 @@ function ConversationGlideScenario() { ); } +/** A conversation with nothing in it yet. */ +function ConversationEmptyScenario() { + return ( +
+ + + + + +
+ ); +} + export const scenarios: Record ReactNode> = { smoke: () => , "approval-card": () => , @@ -470,4 +491,5 @@ export const scenarios: Record ReactNode> = { "conversation-settle": () => , "conversation-stick": () => , "conversation-glide": () => , + "conversation-empty": () => , }; diff --git a/src/ui/conversation.tsx b/src/ui/conversation.tsx index d356f871..13f01e5a 100644 --- a/src/ui/conversation.tsx +++ b/src/ui/conversation.tsx @@ -876,7 +876,8 @@ export type ConversationContentProps = ComponentProps<"div">; * 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`. + * `[&>[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 @@ -935,7 +936,7 @@ export const ConversationContent = forwardRef<
{children}
@@ -1055,7 +1056,10 @@ export type ConversationEmptyStateProps = ComponentProps<"div"> & { readonly icon?: ReactNode; }; -/** No items yet. Children replace the default title, description and icon. */ +/** + * 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, From ae058bebba5642da4a5145a36367c431710f31f3 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 19:40:37 +0545 Subject: [PATCH 10/12] fix(conversation): draw a bare divider's rule as an hr Without a label, ConversationDivider was a div with role separator around a decorative span. Its rule is an hr now, styled as before, so the separator is a real element and the div carries no role. The labelled form is unchanged: its label is read in place and its rules stay decoration. --- src/ui/conversation-divider.tsx | 12 +++++++----- src/ui/conversation.test.tsx | 8 +++++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/ui/conversation-divider.tsx b/src/ui/conversation-divider.tsx index 8da865fd..b6c0032e 100644 --- a/src/ui/conversation-divider.tsx +++ b/src/ui/conversation-divider.tsx @@ -6,8 +6,9 @@ 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 it is a plain - * separator; with one, the label is ordinary text so it is read in place. + * ("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, @@ -18,20 +19,21 @@ export function ConversationDivider({ return (
- {labelled ? ( <> + {children} - ) : null} + ) : ( +
+ )}
); } diff --git a/src/ui/conversation.test.tsx b/src/ui/conversation.test.tsx index 4f803c89..792a829e 100644 --- a/src/ui/conversation.test.tsx +++ b/src/ui/conversation.test.tsx @@ -1282,7 +1282,7 @@ describe("ConversationEmptyState", () => { }); describe("ConversationDivider and ConversationNotice", () => { - test("a bare divider is a separator; a labelled one reads its label", () => { + test("a bare divider is an hr; a labelled one reads its label", () => { const mounted = render( <> @@ -1291,8 +1291,10 @@ describe("ConversationDivider and ConversationNotice", () => { ); const bare = mounted.container.querySelector("[data-testid='bare']"); const day = mounted.container.querySelector("[data-testid='day']"); - expect(bare?.getAttribute("role")).toBe("separator"); - expect(day?.getAttribute("role")).toBeNull(); + 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(); }); From 1836955d96c15e3f69e0ee4603abcfd627cc4187 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 28 Oct 2026 13:52:41 -0700 Subject: [PATCH 11/12] fix(conversation): do not report arrival when the glide target is gone scrollToItem kept calling onEnd(true) after the item unmounted and the view never moved. End that glide as not arrived. --- src/ui/conversation.test.tsx | 38 ++++++++++++++++++++++++++++++++++++ src/ui/conversation.tsx | 9 ++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/ui/conversation.test.tsx b/src/ui/conversation.test.tsx index 792a829e..295aca9b 100644 --- a/src/ui/conversation.test.tsx +++ b/src/ui/conversation.test.tsx @@ -978,6 +978,44 @@ describe("scrollToItem's onEnd", () => { 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; + if (api === null) throw new Error("expected the conversation API"); + api.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" }, diff --git a/src/ui/conversation.tsx b/src/ui/conversation.tsx index 13f01e5a..a5db319d 100644 --- a/src/ui/conversation.tsx +++ b/src/ui/conversation.tsx @@ -505,7 +505,14 @@ function createConversationEngine() { rebase(); const progress = Math.min((now - begin) / duration, 1); const aim = target(); - if (aim !== null) write(glidePosition(glideStart, aim, progress)); + // 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(); From 167a6a4e47d7275ae4c73d943e51090bedc1f302 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 28 Oct 2026 14:02:29 -0700 Subject: [PATCH 12/12] test(conversation): satisfy the typecheck on the mid-glide case The probe assigns the API from a child, so a null check narrowed it to never under tsc. --- src/ui/conversation.test.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ui/conversation.test.tsx b/src/ui/conversation.test.tsx index 295aca9b..acb1aa0a 100644 --- a/src/ui/conversation.test.tsx +++ b/src/ui/conversation.test.tsx @@ -1002,8 +1002,11 @@ describe("scrollToItem's onEnd", () => { layOut(element, ids); await nextFrame(); let arrived: boolean | undefined; - if (api === null) throw new Error("expected the conversation API"); - api.scrollToItem("b", { + // 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; },