diff --git a/e2e/conversation-helpers.ts b/e2e/conversation-helpers.ts new file mode 100644 index 00000000..271baa6c --- /dev/null +++ b/e2e/conversation-helpers.ts @@ -0,0 +1,74 @@ +// Reads and drives the conversation scroll container the e2e scenarios render. +import { expect, type Page } from "@playwright/test"; + +export const container = (page: Page) => + page.locator("[data-slot='conversation-content']"); + +export 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. */ +export 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). */ +export 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. */ +export 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); +}; + +export const atEnd = async (page: Page) => { + await expect + .poll(async () => (await view(page)).distanceFromEnd) + .toBeLessThanOrEqual(1); +}; + +/** The id of the topmost item in view. */ +export 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 ""; + }); + +/** Two frames: one for resize callbacks, one for any correction to land. */ +export const frames = (page: Page) => + page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); diff --git a/e2e/conversation.spec.ts b/e2e/conversation.spec.ts index 263ef542..da721c66 100644 --- a/e2e/conversation.spec.ts +++ b/e2e/conversation.spec.ts @@ -1,67 +1,15 @@ 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 ""; - }); +import { + atEnd, + container, + firstVisible, + frames, + offsetOf, + settled, + view, + wheel, +} from "./conversation-helpers.js"; /** Opens a scenario once its history (which arrives after mount) is in. */ const open = async (page: Page, scenario: string) => { @@ -74,13 +22,7 @@ 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)), - ), - ); + await frames(page); }; test.describe("settle", () => { diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index 1c989356..efdc5af9 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -62,6 +62,8 @@ import { MessageContent, MessageFold, MessageFooter, + ConversationEarlier, + useHistoryWindow, } from "@corbits/react-ui"; import { CommandPalette, @@ -1193,6 +1195,116 @@ function MessageActionsScenario() { ); } +type HistoryRow = { readonly id: string; readonly lines: number }; + +/** Rows of fractional heights, so rounding shows up in the positions. */ +function historyWindowRows(from: number, to: number): HistoryRow[] { + return Array.from({ length: to - from }, (_, index) => ({ + id: `r${from + index}`, + lines: 1 + ((from + index) % 3), + })); +} + +const HISTORY_PAGE = 10; + +/** Renders the window; must sit inside the Conversation it follows. */ +function HistoryRows({ + rows, + hasEarlier, + onLoadEarlier, + distance, +}: { + rows: readonly HistoryRow[]; + hasEarlier: boolean; + onLoadEarlier: () => Promise; + distance: number | undefined; +}) { + const history = useHistoryWindow({ + items: rows, + windowSize: 30, + step: HISTORY_PAGE, + hasEarlier, + onLoadEarlier, + }); + return ( + <> + + + {history.items.map((row) => ( + +

Row {row.id}

+
+ ))} +
+ +
+ + {history.items.length} +
+ + ); +} + +/** + * `local` rows are loaded; `remote` older ones come from a pretend server a + * page at a time, when "Deliver" is pressed. "Deliver nothing" ends a load + * with no rows. + */ +function HistoryWindowScenario({ + local, + remote, + distance, +}: { + local: number; + remote: number; + distance?: number; +}) { + const [from, setFrom] = useState(remote); + const [to, setTo] = useState(remote + local); + const [requests, setRequests] = useState(0); + const [pending, setPending] = useState<(() => void) | null>(null); + const settle = (rows: number) => { + if (pending === null) return; + setFrom((current) => Math.max(0, current - rows)); + setPending(null); + pending(); + }; + return ( +
+ + 0} + onLoadEarlier={() => { + setRequests((count) => count + 1); + return new Promise((resolve) => setPending(() => resolve)); + }} + distance={distance} + /> + +
+ + + + {requests} +
+
+ ); +} + export const scenarios: Record ReactNode> = { smoke: () => , "approval-card": () => , @@ -1245,4 +1357,13 @@ export const scenarios: Record ReactNode> = { ), "message-actions": () => , + "history-window": () => ( + + ), + "history-window-paged": () => ( + + ), + "history-window-reach": () => ( + + ), }; diff --git a/e2e/history-window.spec.ts b/e2e/history-window.spec.ts new file mode 100644 index 00000000..0e755445 --- /dev/null +++ b/e2e/history-window.spec.ts @@ -0,0 +1,196 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { + atEnd, + container, + firstVisible, + frames, + offsetOf, + settled, + view, + wheel, +} from "./conversation-helpers.js"; + +const marker = (page: Page) => + page.locator("[data-slot='conversation-earlier']"); + +const shown = (page: Page) => page.getByRole("status", { name: "Shown" }); + +const requests = (page: Page) => page.getByRole("status", { name: "Requests" }); + +const open = async (page: Page, scenario: string, last: string) => { + await page.goto(`/${scenario}`); + await expect(page.getByTestId(last)).toBeVisible(); + await atEnd(page); +}; + +const loadEarlier = (page: Page) => + page.getByRole("button", { name: "Load earlier", exact: true }).click(); + +/** Scrolls up a little and returns the item being read and where it sits. */ +const readSomewhereUp = async (page: Page) => { + await wheel(page, -300); + const reading = await firstVisible(page); + expect(reading).toMatch(/^r\d+$/); + return { reading, offset: await offsetOf(page, reading) }; +}; + +test.describe("in-memory history", () => { + test("opens at the latest window of items", async ({ page }) => { + await open(page, "history-window", "r99"); + await expect(shown(page)).toHaveText("30"); + await expect(page.getByTestId("r70")).toBeAttached(); + await expect(page.getByTestId("r69")).not.toBeAttached(); + }); + + test("three loads in a row keep the item in view within 1px", async ({ + page, + }) => { + await open(page, "history-window", "r99"); + const { reading, offset } = await readSomewhereUp(page); + for (const row of ["r60", "r50", "r40"]) { + await loadEarlier(page); + await expect(page.getByTestId(row)).toBeAttached(); + await frames(page); + // Against the position before the first load, so drift cannot add up. + expect( + Math.abs((await offsetOf(page, reading)) - offset), + ).toBeLessThanOrEqual(1); + } + await expect(shown(page)).toHaveText("60"); + }); + + test("an append and a trim that cancel out keep the item in view within 1px", async ({ + page, + }) => { + await open(page, "history-window", "r99"); + // Away and back: the view no longer follows the end, so it keeps the + // item being read, and the window is the latest 30 again. + await wheel(page, -300); + await wheel(page, 100_000); + await atEnd(page); + await expect(shown(page)).toHaveText("30"); + const reading = await firstVisible(page); + const offset = await offsetOf(page, reading); + const height = await container(page).evaluate( + (element) => element.scrollHeight, + ); + + // Rows repeat every 3 and the window holds 30, so r100 is exactly as + // tall as r70: the content never resizes, and no resize restores it. + await page.getByRole("button", { name: "Append" }).click(); + await expect(page.getByTestId("r100")).toBeAttached(); + await expect(page.getByTestId("r70")).not.toBeAttached(); + await frames(page); + expect( + await container(page).evaluate((element) => element.scrollHeight), + ).toBe(height); + expect( + Math.abs((await offsetOf(page, reading)) - offset), + ).toBeLessThanOrEqual(1); + }); + + test("scrolled up, new items never trim the window; back at the end it trims", async ({ + page, + }) => { + await open(page, "history-window", "r99"); + const { reading, offset } = await readSomewhereUp(page); + await loadEarlier(page); + await expect(shown(page)).toHaveText("40"); + + for (let count = 0; count < 3; count += 1) { + await page.getByRole("button", { name: "Append" }).click(); + } + await expect(shown(page)).toHaveText("43"); + await expect(page.getByTestId("r60")).toBeAttached(); + await frames(page); + expect( + Math.abs((await offsetOf(page, reading)) - offset), + ).toBeLessThanOrEqual(1); + + await page.getByRole("button", { name: "Scroll to latest" }).click(); + await atEnd(page); + await expect(shown(page)).toHaveText("30"); + await expect(page.getByTestId("r73")).toBeAttached(); + await expect(page.getByTestId("r72")).not.toBeAttached(); + await settled(page); + await atEnd(page); + await expect(page.getByTestId("r102")).toBeVisible(); + }); +}); + +test.describe("server paging", () => { + test("asks once per load, and three pages keep the item in view within 1px", async ({ + page, + }) => { + await open(page, "history-window-paged", "r49"); + await expect(shown(page)).toHaveText("20"); + const { reading, offset } = await readSomewhereUp(page); + + for (const [load, row] of [ + [1, "r20"], + [2, "r10"], + [3, "r0"], + ] as const) { + await loadEarlier(page); + await expect(requests(page)).toHaveText(String(load)); + await expect(marker(page)).toHaveAttribute("data-state", "loading"); + await expect(marker(page)).toHaveText("Loading earlier messages…"); + // Asking again while the page is on its way does nothing. + await loadEarlier(page); + await frames(page); + await expect(requests(page)).toHaveText(String(load)); + + // The page lands above the window, then shows a step at a time. + await page.getByRole("button", { name: "Deliver", exact: true }).click(); + await expect(marker(page)).toHaveAttribute("data-state", "idle"); + await loadEarlier(page); + await expect(page.getByTestId(row)).toBeAttached(); + await frames(page); + expect( + Math.abs((await offsetOf(page, reading)) - offset), + ).toBeLessThanOrEqual(1); + } + await expect(marker(page)).not.toBeAttached(); + await expect(requests(page)).toHaveText("3"); + }); + + test("a load that brings nothing stops asking and offers a button", async ({ + page, + }) => { + await open(page, "history-window-paged", "r49"); + // At the very top the marker is in view, and asks by itself. + await wheel(page, -100_000); + await expect(requests(page)).toHaveText("1"); + await page.getByRole("button", { name: "Deliver nothing" }).click(); + + const retry = page.getByRole("button", { name: "Load earlier messages" }); + await expect(retry).toBeVisible(); + await frames(page); + await frames(page); + await expect(requests(page)).toHaveText("1"); + + await retry.focus(); + await page.keyboard.press("Enter"); + await expect(requests(page)).toHaveText("2"); + await expect(marker(page)).toHaveAttribute("data-state", "loading"); + // The button is gone; focus stays on the marker, not the page body. + expect( + await page.evaluate(() => document.activeElement === document.body), + ).toBe(false); + await expect(marker(page)).toBeFocused(); + }); +}); + +test("scrolling up loads more before the reader reaches the top", async ({ + page, +}) => { + await open(page, "history-window-reach", "r199"); + await expect(shown(page)).toHaveText("30"); + await wheel(page, -600); + await expect + .poll(async () => Number(await shown(page).textContent())) + .toBeGreaterThan(30); + // The marker asked ahead of the reader, who never reached the top. + expect((await view(page)).scrollTop).toBeGreaterThan(0); +}); diff --git a/src/hooks/use-history-window.test.tsx b/src/hooks/use-history-window.test.tsx new file mode 100644 index 00000000..84dcde09 --- /dev/null +++ b/src/hooks/use-history-window.test.tsx @@ -0,0 +1,596 @@ +import { act, Activity, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, test } from "bun:test"; + +import { + Conversation, + ConversationContent, + ConversationItem, + useConversation, + type ConversationContextValue, +} from "../ui/conversation.js"; +import { + useHistoryWindow, + type HistoryWindow, + type UseHistoryWindowOptions, +} from "./use-history-window.js"; + +type Row = { readonly id: string }; + +const rows = (from: number, to: number): Row[] => + Array.from({ length: to - from }, (_, index) => ({ id: `r${from + index}` })); + +type Harness = { + readonly result: { current: HistoryWindow }; + readonly container: HTMLElement; + /** React commits of the host component since mount. */ + readonly commits: () => number; + update(options: UseHistoryWindowOptions): void; + leaveBottom(): Promise; + /** The reader scrolls away from the end, and the next frame has not run. */ + leaveBottomUnseen(): void; + /** The view moves away from the end with no reader input (layout, a glide). */ + driftFromBottom(): Promise; + /** The page glides an item, 1,000px down the content, to the top. */ + jumpTo(id: string): Promise; + /** The content grows by `height` below a view that stays where it is. */ + growBelow(height: number): Promise; + returnToBottom(): Promise; +}; + +const cleanups: (() => void)[] = []; +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup(); +}); + +const nextFrame = () => + act( + () => + new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }), + ); + +function mount( + initial: UseHistoryWindowOptions, +): Harness & { setHidden(hidden: boolean): void } { + const result: { current: HistoryWindow } = { + current: { + items: [], + hasEarlier: false, + loading: false, + stalled: false, + reachStart: () => {}, + }, + }; + let commits = 0; + let conversation: ConversationContextValue | null = null; + + function Host({ options }: { options: UseHistoryWindowOptions }) { + const history = useHistoryWindow(options); + result.current = history; + conversation = useConversation(); + useEffect(() => { + commits += 1; + }); + return ( + + {history.items.map((row) => ( + + {row.id} + + ))} + + ); + } + + const container = document.createElement("div"); + document.body.appendChild(container); + let root!: Root; + let latest = initial; + let hidden = false; + const render = (options: UseHistoryWindowOptions) => { + latest = options; + root.render( + + + + + , + ); + }; + act(() => { + root = createRoot(container); + render(initial); + }); + cleanups.push(() => { + act(() => root.unmount()); + container.remove(); + }); + + const scroller = container.querySelector( + "[data-slot='conversation-content']", + ); + if (scroller === null) throw new Error("expected the scroll container"); + // happy-dom does no layout: give the container a tall content box. + let height = 100_000; + Object.defineProperty(scroller, "scrollHeight", { + configurable: true, + get: () => height, + }); + Object.defineProperty(scroller, "clientHeight", { + configurable: true, + value: 500, + }); + const scroll = (top: number, byReader: boolean) => { + if (byReader) scroller.dispatchEvent(new WheelEvent("wheel")); + scroller.scrollTop = top; + scroller.dispatchEvent(new Event("scroll")); + }; + /** A scroll to `top`, landing on the next frame. */ + const scrollTo = async (top: number, byReader: boolean) => { + scroll(top, byReader); + await nextFrame(); + }; + + return { + result, + container, + commits: () => commits, + update: (options) => act(() => render(options)), + leaveBottom: () => scrollTo(1_000, true), + leaveBottomUnseen: () => scroll(1_000, true), + driftFromBottom: () => scrollTo(1_000, false), + jumpTo: async (id) => { + const item = scroller.querySelector( + `[data-item-id='${id}']`, + ); + if (item === null || conversation === null) { + throw new Error(`expected item ${id} in a conversation`); + } + item.getBoundingClientRect = () => + ({ top: 1_000 - scroller.scrollTop }) as DOMRect; + const { scrollToItem } = conversation; + await act( + () => + new Promise((resolve) => { + scrollToItem(id, { onEnd: () => resolve() }); + }), + ); + // The scroll event the browser fires for the glide's last write. + scroller.dispatchEvent(new Event("scroll")); + await nextFrame(); + }, + growBelow: async (by) => { + height += by; + scroller.dispatchEvent(new Event("scroll")); + await nextFrame(); + }, + returnToBottom: () => scrollTo(99_500, true), + setHidden: (next) => { + hidden = next; + act(() => render(latest)); + }, + }; +} + +const ids = (history: HistoryWindow) => history.items.map((row) => row.id); +const first = (history: HistoryWindow) => history.items[0]?.id; + +function deferred() { + let resolve: () => void = () => {}; + let reject: (reason: unknown) => void = () => {}; + const promise = new Promise((settle, fail) => { + resolve = settle; + reject = fail; + }); + return { promise, resolve, reject }; +} + +describe("useHistoryWindow with in-memory history", () => { + test("starts at the latest windowSize items", () => { + const { result } = mount({ items: rows(0, 300) }); + expect(result.current.items).toHaveLength(120); + expect(first(result.current)).toBe("r180"); + expect(result.current.items.at(-1)?.id).toBe("r299"); + expect(result.current.hasEarlier).toBe(true); + expect(result.current.loading).toBe(false); + }); + + test("reachStart grows the window by step until the first item", () => { + const { result } = mount({ items: rows(0, 300), step: 80 }); + act(() => result.current.reachStart()); + expect(first(result.current)).toBe("r100"); + act(() => result.current.reachStart()); + expect(first(result.current)).toBe("r20"); + act(() => result.current.reachStart()); + expect(first(result.current)).toBe("r0"); + expect(result.current.items).toHaveLength(300); + expect(result.current.hasEarlier).toBe(false); + }); + + test("freezes while the reader is scrolled up and trims back at the bottom", async () => { + const harness = mount({ items: rows(0, 300) }); + const { result } = harness; + await harness.leaveBottom(); + + // New items arrive below: nothing above the reader is removed. + harness.update({ items: rows(0, 305) }); + expect(first(result.current)).toBe("r180"); + expect(result.current.items).toHaveLength(125); + + act(() => result.current.reachStart()); + expect(first(result.current)).toBe("r120"); + expect(result.current.items).toHaveLength(185); + + await harness.returnToBottom(); + expect(first(result.current)).toBe("r185"); + expect(result.current.items).toHaveLength(120); + }); + + test("a move up that was not the reader's freezes the window too", async () => { + const harness = mount({ items: rows(0, 300) }); + // Away and back once, so the conversation no longer follows the end and + // a layout scroll can leave it. + await harness.leaveBottom(); + await harness.returnToBottom(); + // Let the reader's gesture, and its momentum allowance, end. + await act(() => new Promise((resolve) => setTimeout(resolve, 200))); + await harness.driftFromBottom(); + harness.update({ items: rows(0, 301) }); + expect(first(harness.result.current)).toBe("r180"); + expect(harness.result.current.items).toHaveLength(121); + }); + + test("a jump to an item keeps it in the window", async () => { + const harness = mount({ items: rows(0, 300) }); + await harness.returnToBottom(); + await act(() => new Promise((resolve) => setTimeout(resolve, 200))); + await harness.jumpTo("r180"); + harness.update({ items: rows(0, 301) }); + expect(first(harness.result.current)).toBe("r180"); + expect( + harness.container.querySelector("[data-item-id='r180']"), + ).not.toBeNull(); + }); + + test("content growing below a view that stays put slides the window", async () => { + const harness = mount({ items: rows(0, 300) }); + await harness.leaveBottom(); + await harness.returnToBottom(); + await act(() => new Promise((resolve) => setTimeout(resolve, 200))); + // The conversation no longer follows: the end moves away below the view. + await harness.growBelow(5_000); + harness.update({ items: rows(0, 301) }); + expect(first(harness.result.current)).toBe("r181"); + expect(harness.result.current.items).toHaveLength(120); + }); + + test("at the bottom, new items slide the window", () => { + const harness = mount({ items: rows(0, 300) }); + harness.update({ items: rows(0, 301) }); + expect(first(harness.result.current)).toBe("r181"); + expect(harness.result.current.items).toHaveLength(120); + }); + + test("a window grown at the bottom shrinks with the next item", () => { + const harness = mount({ items: rows(0, 300) }); + act(() => harness.result.current.reachStart()); + expect(harness.result.current.items).toHaveLength(180); + harness.update({ items: rows(0, 301) }); + expect(first(harness.result.current)).toBe("r181"); + }); + + test("an item that lands before the frame the reader left in removes nothing", async () => { + const harness = mount({ items: rows(0, 300) }); + const { result } = harness; + await harness.returnToBottom(); + // A host that commits synchronously within the frame (flushSync in a + // frame-batched stream) lands its item before the frame's scroll pass. + harness.leaveBottomUnseen(); + harness.update({ items: rows(0, 301) }); + expect(first(result.current)).toBe("r180"); + expect(result.current.items).toHaveLength(121); + + // The frame then sees the reader's scroll and freezes the window. + await nextFrame(); + harness.update({ items: rows(0, 302) }); + expect(first(result.current)).toBe("r180"); + expect(result.current.items).toHaveLength(122); + }); + + test("an id swapped at the end as the reader leaves removes nothing", async () => { + const harness = mount({ items: rows(0, 300) }); + const { result } = harness; + await harness.returnToBottom(); + harness.leaveBottomUnseen(); + // The last item's id is replaced (an optimistic id swapped for the + // server's) and a reply appended, in one update. + const swapped = [...rows(0, 299), { id: "r299-server" }, { id: "r300" }]; + harness.update({ items: swapped }); + expect(first(result.current)).toBe("r180"); + + // The frame then sees the reader's scroll and freezes the window there. + await nextFrame(); + harness.update({ items: [...swapped, { id: "r301" }] }); + expect(first(result.current)).toBe("r180"); + }); + + test("an id swapped at the end at the bottom still slides the window", () => { + const harness = mount({ items: rows(0, 300) }); + const swapped = [...rows(0, 299), { id: "r299-server" }, { id: "r300" }]; + harness.update({ items: swapped }); + expect(first(harness.result.current)).toBe("r181"); + expect(harness.result.current.items).toHaveLength(120); + }); + + test("a removed start item keeps the rest of the window", async () => { + const harness = mount({ items: rows(0, 300) }); + await harness.leaveBottom(); + harness.update({ items: rows(0, 301) }); + // The pinned start, r180, and the item after it are deleted. + const items = rows(0, 301).filter( + (row) => row.id !== "r180" && row.id !== "r181", + ); + harness.update({ items }); + expect(first(harness.result.current)).toBe("r182"); + expect(harness.result.current.items).toHaveLength(119); + }); + + test("a removed start item falls back within the window rendered last, even while hidden", async () => { + const harness = mount({ items: rows(0, 300) }); + await harness.leaveBottom(); + // Hidden by Activity, the window keeps rendering with no layout effects. + harness.setHidden(true); + act(() => harness.result.current.reachStart()); + expect(first(harness.result.current)).toBe("r120"); + harness.update({ items: rows(0, 300).filter((row) => row.id !== "r120") }); + expect(first(harness.result.current)).toBe("r121"); + }); +}); + +describe("useHistoryWindow with server paging", () => { + test("asks the host only once the local items are all shown", async () => { + let calls = 0; + const { result } = mount({ + items: rows(0, 300), + hasEarlier: true, + onLoadEarlier: () => { + calls += 1; + }, + }); + act(() => result.current.reachStart()); + act(() => result.current.reachStart()); + expect(calls).toBe(0); + act(() => result.current.reachStart()); + expect(first(result.current)).toBe("r0"); + expect(calls).toBe(0); + // A loader that returns nothing settles on the next microtask. + await act(async () => result.current.reachStart()); + expect(calls).toBe(1); + }); + + test("calls onLoadEarlier once until it resolves, then reveals the page a step at a time", async () => { + let calls = 0; + let load = deferred(); + let items = rows(250, 300); + const options = (): UseHistoryWindowOptions => ({ + items, + step: 20, + hasEarlier: true, + onLoadEarlier: () => { + calls += 1; + return load.promise; + }, + }); + const harness = mount(options()); + const { result } = harness; + expect(result.current.items).toHaveLength(50); + expect(result.current.hasEarlier).toBe(true); + + act(() => result.current.reachStart()); + expect(calls).toBe(1); + expect(result.current.loading).toBe(true); + act(() => result.current.reachStart()); + act(() => result.current.reachStart()); + expect(calls).toBe(1); + + // The host prepends its page, then its load resolves. + items = rows(200, 300); + harness.update(options()); + await act(async () => { + load.resolve(); + await load.promise; + }); + expect(result.current.loading).toBe(false); + expect(result.current.stalled).toBe(false); + // The page landed above the window, which stays where it was. + expect(first(result.current)).toBe("r250"); + expect(result.current.hasEarlier).toBe(true); + + act(() => result.current.reachStart()); + expect(first(result.current)).toBe("r230"); + expect(calls).toBe(1); + act(() => result.current.reachStart()); + act(() => result.current.reachStart()); + expect(first(result.current)).toBe("r200"); + + load = deferred(); + act(() => result.current.reachStart()); + expect(calls).toBe(2); + expect(result.current.loading).toBe(true); + }); + + test("a load that brings nothing stalls until asked again", async () => { + let calls = 0; + let load = deferred(); + const harness = mount({ + items: rows(250, 300), + hasEarlier: true, + onLoadEarlier: () => { + calls += 1; + return load.promise; + }, + }); + const { result } = harness; + act(() => result.current.reachStart()); + await act(async () => { + load.resolve(); + await load.promise; + }); + expect(result.current.loading).toBe(false); + expect(result.current.stalled).toBe(true); + expect(calls).toBe(1); + + // An explicit retry asks again; a rejection stalls the same way. + load = deferred(); + act(() => result.current.reachStart()); + expect(calls).toBe(2); + expect(result.current.stalled).toBe(false); + await act(async () => { + load.reject(new Error("offline")); + await load.promise.catch(() => {}); + }); + expect(result.current.loading).toBe(false); + expect(result.current.stalled).toBe(true); + }); + + test("a stall never blocks items already held above the window", async () => { + let calls = 0; + const options: UseHistoryWindowOptions = { + items: rows(0, 300), + hasEarlier: true, + onLoadEarlier: () => { + calls += 1; + return Promise.reject(new Error("offline")); + }, + }; + const harness = mount(options); + const { result } = harness; + act(() => result.current.reachStart()); + act(() => result.current.reachStart()); + act(() => result.current.reachStart()); + expect(first(result.current)).toBe("r0"); + await act(async () => result.current.reachStart()); + expect(calls).toBe(1); + expect(result.current.stalled).toBe(true); + + // Back at the bottom the window trims, and 180 held items sit above it. + await harness.leaveBottom(); + await harness.returnToBottom(); + expect(first(result.current)).toBe("r180"); + expect(result.current.hasEarlier).toBe(true); + expect(result.current.stalled).toBe(false); + + // Revealing them asks nothing of the host; at items[0] the stall is back. + act(() => result.current.reachStart()); + act(() => result.current.reachStart()); + act(() => result.current.reachStart()); + expect(first(result.current)).toBe("r0"); + expect(calls).toBe(1); + expect(result.current.stalled).toBe(true); + }); + + test("a loader that throws stalls and reports the error once", async () => { + const reported: unknown[] = []; + const original = globalThis.reportError; + globalThis.reportError = (error: unknown) => { + reported.push(error); + }; + try { + const failure = new Error("offline"); + const { result } = mount({ + items: rows(250, 300), + hasEarlier: true, + onLoadEarlier: () => { + throw failure; + }, + }); + // Reached from the marker's observer callback: nothing may escape it. + await act(async () => result.current.reachStart()); + expect(reported).toEqual([failure]); + expect(result.current.loading).toBe(false); + expect(result.current.stalled).toBe(true); + } finally { + globalThis.reportError = original; + } + }); + + test("a load that settles while hidden by Activity is not lost", async () => { + const load = deferred(); + let options: UseHistoryWindowOptions = { + items: rows(250, 300), + hasEarlier: true, + onLoadEarlier: () => load.promise, + }; + const harness = mount(options); + act(() => harness.result.current.reachStart()); + expect(harness.result.current.loading).toBe(true); + + harness.setHidden(true); + options = { ...options, items: rows(200, 300) }; + harness.update(options); + await act(async () => { + load.resolve(); + await load.promise; + }); + harness.setHidden(false); + expect(harness.result.current.loading).toBe(false); + expect(harness.result.current.stalled).toBe(false); + }); + + test("a page that arrives after its load settled clears the stall", async () => { + const load = deferred(); + const harness = mount({ + items: rows(250, 300), + hasEarlier: true, + onLoadEarlier: () => load.promise, + }); + act(() => harness.result.current.reachStart()); + await act(async () => { + load.resolve(); + await load.promise; + }); + expect(harness.result.current.stalled).toBe(true); + harness.update({ + items: rows(200, 300), + hasEarlier: true, + onLoadEarlier: () => load.promise, + }); + expect(harness.result.current.stalled).toBe(false); + }); +}); + +describe("useHistoryWindow ceiling", () => { + test("10,000 items: the window, not the history, sets the work", async () => { + let items = rows(0, 10_000); + const harness = mount({ items }); + const { result } = harness; + expect(result.current.items).toHaveLength(120); + + // An unrelated render reuses the window: no new slice, no new array. + const before = result.current.items; + harness.update({ items }); + expect(result.current.items).toBe(before); + + await harness.leaveBottom(); + for (let load = 1; load <= 3; load += 1) { + const commits = harness.commits(); + act(() => result.current.reachStart()); + expect(harness.commits() - commits).toBe(1); + expect(result.current.items).toHaveLength(120 + load * 60); + } + + await harness.returnToBottom(); + expect(result.current.items).toHaveLength(120); + expect(first(result.current)).toBe("r9880"); + + // A long run of new items at the bottom never grows the window. + for (let count = 1; count <= 50; count += 1) { + items = rows(0, 10_000 + count); + harness.update({ items }); + expect(result.current.items).toHaveLength(120); + } + expect(ids(result.current).at(-1)).toBe("r10049"); + }); +}); diff --git a/src/hooks/use-history-window.ts b/src/hooks/use-history-window.ts new file mode 100644 index 00000000..023e39d9 --- /dev/null +++ b/src/hooks/use-history-window.ts @@ -0,0 +1,304 @@ +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; + +import { CONVERSATION_END_THRESHOLD } from "../lib/conversation-scroll.js"; +import { useConversation } from "../ui/conversation.js"; + +/** Where the window starts: `windowSize` items up to `through`, the last + * item when the window was last trimmed, or a pinned item. Either way a new + * item only adds to the window until it is trimmed again. */ +type WindowStart = + | { readonly kind: "latest"; readonly through: string | undefined } + | { readonly kind: "item"; readonly id: string }; + +/** A load the host was asked for: the first item it had when asked. */ +type EarlierRequest = { + readonly first: string | undefined; + readonly settled: boolean; +}; + +type WindowState = { + readonly start: WindowStart; + readonly request: EarlierRequest | null; + /** The `atBottom` seen at the last render, so a change is noticed once. */ + readonly atBottom: boolean; + /** The window at the last render: a start whose item is gone falls back + * to what is left of it. */ + readonly shown: readonly T[]; +}; + +export type UseHistoryWindowOptions = { + /** Every item the host holds, oldest first. Ids must stay stable across + * pages: the reader's position is kept by id. */ + readonly items: readonly T[]; + /** How many of the latest items the window holds at the bottom. Defaults + * to 120. It should cover the view plus the marker's reach at the + * smallest item height, or each new item at the bottom trims a page that + * is loaded straight back. */ + readonly windowSize?: number | undefined; + /** How many more items each load near the top brings in. Defaults to 60. */ + readonly step?: number | undefined; + /** The host has older items than `items[0]` that it can page in. */ + readonly hasEarlier?: boolean | undefined; + /** + * Called when the reader nears the start of `items` and `hasEarlier` is + * true: prepend the next page to `items`. Called once until the returned + * promise settles. A load that settles without a new first item (an empty + * page, a rejection or a throw) leaves the window `stalled`. A synchronous + * throw is reported through `reportError`; report a rejection from here, + * the hook only stops asking. + */ + readonly onLoadEarlier?: (() => Promise | void) | undefined; +}; + +export type HistoryWindow = { + /** The items to render, oldest first. */ + readonly items: readonly T[]; + /** Older items exist, in `items` before the window or with the host. */ + readonly hasEarlier: boolean; + /** `onLoadEarlier` is in flight. */ + readonly loading: boolean; + /** The window starts at the first item and the last load brought nothing + * new: stop asking on scroll and offer a retry. Clears when a new first + * item arrives, and never holds back items already above the window. */ + readonly stalled: boolean; + /** The reader is near the start of the window: show `step` more items, or + * ask the host for a page once the window holds every item it has. */ + readonly reachStart: () => void; +}; + +function startIndex( + items: readonly { readonly id: string }[], + start: WindowStart, + windowSize: number, + previous: readonly { readonly id: string }[], +): number { + if (start.kind === "latest") { + const end = items.findIndex((item) => item.id === start.through); + if (end !== -1) return Math.max(0, end + 1 - windowSize); + } else { + const at = items.findIndex((item) => item.id === start.id); + if (at !== -1) return at; + } + // The item the start is kept by is gone (deleted, or its id replaced): + // start at the first item of the last window that still exists, so nothing + // above the reader goes with it. At the bottom, the next commit trims back. + const ids = new Set(items.map((item) => item.id)); + const kept = previous.find((item) => ids.has(item.id)); + return kept === undefined + ? Math.max(0, items.length - windowSize) + : items.findIndex((item) => item.id === kept.id); +} + +/** + * Renders a window of a long conversation: the latest `windowSize` items, + * with older ones added `step` at a time as the reader nears the top. Once + * the view moves up away from the end, by the reader's scroll or a jump to + * an item, the window only grows, so nothing they are reading is removed; + * back at the bottom it shrinks to `windowSize` again. + * Each load keeps the item being read where it was, through the + * conversation's `captureAnchor` / `restoreAnchor`. + * + * Call it inside ``, in the component that renders + * `ConversationContent`, and put a `ConversationEarlier` first in the + * content, wired to `hasEarlier`, `loading`, `stalled` and `reachStart`. + * Key the `` by conversation, so a pinned window never carries + * over to another one. + * + * Two sources work the same way. In-memory history passes every item and + * no `hasEarlier`. Server paging passes what it has loaded, `hasEarlier`, + * and an `onLoadEarlier` that prepends the next page; a landed page is + * revealed `step` items at a time like local history, so a large page never + * mounts at once. + * + * ## The ceiling + * + * This windows; it does not virtualise. Every item in the window is a real, + * mounted element in normal flow, so the browser's own scrolling, momentum + * and scrollbar apply, find-in-page and focus reach everything in the + * window, and items of any height (streaming text, images that load late) + * need no measuring or estimating. The cost is that the window grows as far + * as the reader scrolls back in one sitting, and is trimmed only when they + * return to the bottom. + * + * It is built for conversations up to about 10,000 items held in memory. + * The window mounts `windowSize` items at the bottom (120 by default) and + * `step` more per load. Each render does one linear id lookup over `items` + * and slices the window; the slice is reused until `items` or the window + * start change (a new slice is recorded in state, which runs the host's + * body once more before its children render), and scrolling re-renders the + * host only when the reader leaves or reaches the bottom. A new item at the + * bottom commits twice: added, then trimmed once the view is read to still + * be at the end. + * + * Page from the server instead (`hasEarlier`, `onLoadEarlier`) when the + * history is longer than that, when items are heavy enough that holding + * them all costs memory (large tool outputs, images), or when most readers + * never scroll back far. If readers routinely read back through thousands + * of items in one go, the mounted window itself becomes the cost, and + * virtualisation is the better trade despite losing find-in-page. + */ +export function useHistoryWindow({ + items, + windowSize = 120, + step = 60, + hasEarlier = false, + onLoadEarlier, +}: UseHistoryWindowOptions): HistoryWindow { + const { + atBottom, + captureAnchor, + restoreAnchor, + scrollContainer, + subscribeScroll, + } = useConversation(); + const lastId = items.at(-1)?.id; + const [state, setState] = useState>(() => ({ + start: { kind: "latest", through: lastId }, + request: null, + atBottom, + shown: [], + })); + + let next = state; + let start = state.start; + if (state.atBottom !== atBottom) { + // Back at the bottom: trim back. The conversation's anchor is current + // from the reader's last scroll. + if (atBottom) start = { kind: "latest", through: lastId }; + next = { ...next, start, atBottom }; + } + + const index = useMemo( + () => startIndex(items, start, windowSize, state.shown), + [items, start, windowSize, state.shown], + ); + const shown = useMemo(() => items.slice(index), [items, index]); + if (state.shown !== shown) next = { ...next, shown }; + if (next !== state) setState(next); + const firstId = shown[0]?.id; + + const request = state.request; + const loading = request !== null && !request.settled; + // The host is only asked from items[0], so its stall applies only there; + // items held above the window are always revealed. + const stalled = + index === 0 && + request !== null && + request.settled && + items[0]?.id === request.first; + + const latest = useRef({ items, index, step, hasEarlier, onLoadEarlier }); + const pending = useRef(null); + useLayoutEffect(() => { + latest.current = { items, index, step, hasEarlier, onLoadEarlier }; + }); + + // Items above the reader came or went: put what they were reading back. + // Also needed when an append and a trim cancel out, so nothing resizes. + const seenFirst = useRef(firstId); + useLayoutEffect(() => { + if (firstId === seenFirst.current) return; + seenFirst.current = firstId; + restoreAnchor(); + }, [firstId, restoreAnchor]); + + // A new last item, committed with nothing removed: trim back to + // `windowSize` at the bottom, or slide the window while it is not frozen. + // `atBottom` and the freeze are a frame old, so read the view first: if it + // moved towards the top since the last frame's scroll pass, a reader may + // have scrolled away unseen. The window then keeps the item, and that + // pass freezes it. + const frameTop = useRef(null); + const seenLast = useRef(lastId); + useLayoutEffect(() => { + if (lastId === seenLast.current) return; + seenLast.current = lastId; + const scroller = scrollContainer(); + const top = frameTop.current; + if (scroller !== null && top !== null && scroller.scrollTop < top - 0.5) { + return; + } + setState((current) => { + const { start } = current; + if (start.kind === "item" && !atBottom) return current; + if (start.kind === "latest" && start.through === lastId) return current; + return { ...current, start: { kind: "latest", through: lastId } }; + }); + }, [lastId, atBottom, scrollContainer]); + + // The view leaves the end: freeze the start, so new items no longer slide + // the window over what is in view. Any move up counts, not only the + // reader's: a jump to an item, focus or find in page put something there to + // be read. Content growing below a view that stays put does not, so the + // window still slides then. Subscribed in a layout effect, so the first + // frame's pass, which reports the view once the container mounts, is never + // missed. + useLayoutEffect( + () => + subscribeScroll((snapshot) => { + frameTop.current = snapshot.scrollTop; + if (snapshot.distanceFromEnd <= CONVERSATION_END_THRESHOLD) return; + if (!snapshot.byReader && snapshot.delta >= 0) return; + const { items, index } = latest.current; + const first = items[index]?.id; + if (first === undefined) return; + setState((current) => + current.start.kind === "latest" + ? { ...current, start: { kind: "item", id: first } } + : current, + ); + }), + [subscribeScroll], + ); + + const reachStart = useCallback(() => { + const { items, index, step, hasEarlier, onLoadEarlier } = latest.current; + const earlier = items[Math.max(0, index - step)]; + if (index > 0 && earlier !== undefined) { + captureAnchor(); + setState((current) => ({ + ...current, + start: { kind: "item", id: earlier.id }, + })); + return; + } + if (!hasEarlier || onLoadEarlier === undefined) return; + if (pending.current !== null) return; + const token = {}; + pending.current = token; + const first = items[0]?.id; + // Pin the start, so the page lands above the window and is revealed a + // step at a time rather than all at once. + setState((current) => ({ + ...current, + start: first === undefined ? current.start : { kind: "item", id: first }, + request: { first, settled: false }, + })); + const settle = () => { + if (pending.current !== token) return; + pending.current = null; + setState((current) => ({ + ...current, + request: { first, settled: true }, + })); + }; + let result: Promise | void = undefined; + try { + result = onLoadEarlier(); + } catch (error) { + // A failed load, like a rejection. Thrown on, it would escape the + // marker's observer callback, so report it instead. + if (typeof reportError === "function") reportError(error); + else console.error(error); + } + void Promise.resolve(result).then(settle, settle); + }, [captureAnchor]); + + return { + items: shown, + hasEarlier: index > 0 || hasEarlier, + loading, + stalled, + reachStart, + }; +} diff --git a/src/index.ts b/src/index.ts index b3349ff5..014d1699 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ export * from "./hooks/use-resizable.js"; export * from "./hooks/use-tabs.js"; export * from "./hooks/use-tab-drag.js"; export * from "./hooks/use-copy.js"; +export * from "./hooks/use-history-window.js"; export * from "./lib/artifact.js"; export * from "./lib/chart-palette.js"; export * from "./lib/chat-message.js"; @@ -148,3 +149,4 @@ export * from "./ui/message-fold.js"; export * from "./ui/message-actions.js"; export * from "./ui/message-reactions.js"; export * from "./ui/message-replies.js"; +export * from "./ui/conversation-earlier.js"; diff --git a/src/ui/conversation-earlier.test.tsx b/src/ui/conversation-earlier.test.tsx new file mode 100644 index 00000000..6a8ee333 --- /dev/null +++ b/src/ui/conversation-earlier.test.tsx @@ -0,0 +1,216 @@ +import { act, createRef } from "react"; +import type { ReactElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { + Conversation, + ConversationContent, + ConversationItem, +} from "./conversation.js"; +import { ConversationEarlier } from "./conversation-earlier.js"; + +/** happy-dom never reports intersections: record observers and drive them. */ +class FakeObserver { + static live = new Set(); + readonly observed = new Set(); + readonly observes: Element[] = []; + + constructor( + readonly callback: IntersectionObserverCallback, + readonly options: IntersectionObserverInit = {}, + ) {} + + observe(target: Element) { + FakeObserver.live.add(this); + this.observed.add(target); + this.observes.push(target); + } + + unobserve(target: Element) { + this.observed.delete(target); + } + + disconnect() { + this.observed.clear(); + FakeObserver.live.delete(this); + } + + /** Reports every observed element as in reach. */ + intersect() { + const records = [...this.observed].map( + (target) => + ({ target, isIntersecting: true }) as IntersectionObserverEntry, + ); + // The cast stands in for the parts of an entry this component never reads. + this.callback(records, this as unknown as IntersectionObserver); + } +} + +const original = globalThis.IntersectionObserver; +beforeEach(() => { + FakeObserver.live.clear(); + globalThis.IntersectionObserver = + FakeObserver as unknown as typeof IntersectionObserver; +}); + +const cleanups: (() => void)[] = []; +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup(); + globalThis.IntersectionObserver = original; +}); + +function render(node: ReactElement) { + const container = document.createElement("div"); + document.body.appendChild(container); + let root!: Root; + act(() => { + root = createRoot(container); + root.render(node); + }); + cleanups.push(() => { + act(() => root.unmount()); + container.remove(); + }); + return { + container, + rerender: (next: ReactElement) => act(() => root.render(next)), + }; +} + +const observer = () => { + const [only, ...rest] = FakeObserver.live; + if (only === undefined || rest.length > 0) + throw new Error(`expected one observer, found ${FakeObserver.live.size}`); + return only; +}; + +function Marker({ + ids = ["a"], + ...props +}: { + ids?: string[]; + hasEarlier?: boolean; + loading?: boolean; + stalled?: boolean; + onReachStart: () => void; +}) { + return ( + + + + {ids.map((id) => ( + + {id} + + ))} + + + ); +} + +const marker = (container: HTMLElement) => + container.querySelector("[data-slot='conversation-earlier']"); + +describe("ConversationEarlier", () => { + test("renders nothing without earlier items", () => { + const { container } = render( + {}} />, + ); + expect(marker(container)).toBeNull(); + expect(FakeObserver.live.size).toBe(0); + }); + + test("forwards its ref to the marker", () => { + const ref = createRef(); + const { container } = render( + + + {}} /> + + , + ); + expect(ref.current).not.toBeNull(); + expect(marker(container)).toBe(ref.current); + }); + + test("asks for more when it comes within reach of the view", () => { + let reached = 0; + const { container } = render( + (reached += 1)} />, + ); + const scroller = container.querySelector( + "[data-slot='conversation-content']", + ); + expect(observer().options.root).toBe(scroller); + expect(observer().options.rootMargin).toBe("1200px 0px 0px 0px"); + act(() => observer().intersect()); + expect(reached).toBe(1); + expect(marker(container)?.textContent).toBe("Earlier messages"); + }); + + test("looks again when the conversation's items change", async () => { + const { rerender } = render( {}} />); + const watching = observer(); + expect(watching.observes).toHaveLength(1); + rerender( {}} />); + // The registry tells its listeners once per microtask. + await act(async () => {}); + expect(watching.observes).toHaveLength(2); + }); + + test("shows the load and stops asking while it is in flight", () => { + const { container } = render( {}} />); + expect(FakeObserver.live.size).toBe(0); + const element = marker(container); + expect(element?.getAttribute("aria-busy")).toBe("true"); + expect(element?.dataset["state"]).toBe("loading"); + expect(element?.textContent).toBe("Loading earlier messages…"); + }); + + test("stalled, it stops asking and offers a button that asks again", () => { + let reached = 0; + const { container } = render( + (reached += 1)} />, + ); + expect(FakeObserver.live.size).toBe(0); + const button = container.querySelector("button"); + expect(button?.textContent).toBe("Load earlier messages"); + act(() => button?.click()); + expect(reached).toBe(1); + }); + + test("a retry keeps focus in the marker, and returns it to the button", () => { + const { container, rerender } = render( + {}} />, + ); + const button = container.querySelector("button"); + if (button === null) throw new Error("expected the retry button"); + button.focus(); + act(() => button.click()); + rerender( {}} />); + expect(document.activeElement).not.toBe(document.body); + expect(document.activeElement).toBe(marker(container)); + + rerender( {}} />); + expect(document.activeElement).toBe(container.querySelector("button")); + }); + + test("when the last page lands, focus moves from the marker to the log", () => { + const { container, rerender } = render( + {}} />, + ); + const button = container.querySelector("button"); + if (button === null) throw new Error("expected the retry button"); + button.focus(); + act(() => button.click()); + rerender( {}} />); + expect(document.activeElement).toBe(marker(container)); + + rerender( {}} />); + expect(marker(container)).toBeNull(); + expect(document.activeElement).toBe( + container.querySelector("[role='log']"), + ); + }); +}); diff --git a/src/ui/conversation-earlier.tsx b/src/ui/conversation-earlier.tsx new file mode 100644 index 00000000..c2774bb5 --- /dev/null +++ b/src/ui/conversation-earlier.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { LoaderCircle } from "lucide-react"; +import { + forwardRef, + useCallback, + useEffect, + useLayoutEffect, + useRef, +} from "react"; +import type { ComponentProps } from "react"; + +import { cn } from "../lib/utils.js"; +import { Button } from "./button.js"; +import { useConversation } from "./conversation.js"; + +export type ConversationEarlierProps = ComponentProps<"div"> & { + /** Older items exist; without them the marker renders nothing. */ + readonly hasEarlier: boolean; + /** Older items are on their way. */ + readonly loading?: boolean | undefined; + /** The last load brought nothing: stop asking on scroll and show a button + * that asks again. */ + readonly stalled?: boolean | undefined; + /** The marker came within `distance` of the view: show more. */ + readonly onReachStart: () => void; + /** How far above the view, in pixels, the marker starts asking, so older + * items arrive before the reader reaches the top. Defaults to 1200. */ + readonly distance?: number | undefined; +}; + +/** + * Sits first in `ConversationContent` while older items exist, and asks for + * them (`onReachStart`) as it nears the view, so history loads as the reader + * scrolls up. It asks again whenever the conversation's items change or a + * load ends, in case it is still in reach. Wire it to `useHistoryWindow`. + * + * It shows "Loading earlier messages…" while a load is in flight, and a + * "Load earlier messages" button once a load comes back empty. Children + * replace the text. Its height is the same in every state, so a change never + * moves the reader. Pressing the button moves focus to the marker while the + * load runs, and back to the button if it stalls again. + */ +export const ConversationEarlier = forwardRef< + HTMLDivElement, + ConversationEarlierProps +>(function ConversationEarlier( + { + hasEarlier, + loading = false, + stalled = false, + onReachStart, + distance = 1200, + className, + children, + onFocus, + onBlur, + ...props + }, + ref, +) { + const { items, scrollContainer } = useConversation(); + const markerRef = useRef(null); + const reach = useRef(onReachStart); + useLayoutEffect(() => { + reach.current = onReachStart; + }); + /** The retry button had focus when pressed, and is about to unmount. */ + const retried = useRef(false); + + // A retry swaps the button for the loading text: hold focus on the marker + // rather than let it fall to the body, and hand it back to the button if + // the retry stalls again. + useLayoutEffect(() => { + const element = markerRef.current; + if (element === null) return; + if (stalled) { + if (document.activeElement === element) { + element.querySelector("button")?.focus(); + } + return; + } + if (!retried.current) return; + retried.current = false; + const active = document.activeElement; + if (active === null || active === document.body) { + element.focus({ preventScroll: true }); + } + }, [stalled]); + + // Once the last page has landed the marker goes away; if it held focus, + // hand focus to the log so it doesn't fall to the body. + const focused = useRef(false); + useLayoutEffect(() => { + if (hasEarlier || !focused.current) return; + focused.current = false; + const active = document.activeElement; + if (active === null || active === document.body) { + scrollContainer()?.focus({ preventScroll: true }); + } + }, [hasEarlier, scrollContainer]); + + const setMarker = useCallback( + (element: HTMLDivElement | null) => { + markerRef.current = element; + if (typeof ref === "function") ref(element); + else if (ref !== null && ref !== undefined) ref.current = element; + }, + [ref], + ); + + useEffect(() => { + const marker = markerRef.current; + if (!hasEarlier || loading || stalled || marker === null) return; + const observer = new IntersectionObserver( + (records) => { + if (records.some((record) => record.isIntersecting)) reach.current(); + }, + { + root: scrollContainer(), + rootMargin: `${distance}px 0px 0px 0px`, + }, + ); + observer.observe(marker); + // Observing afresh reports where the marker is now: still in reach after + // a page of items went in above the reader means ask again. + const stop = items.subscribe(() => { + observer.unobserve(marker); + observer.observe(marker); + }); + return () => { + stop(); + observer.disconnect(); + }; + }, [hasEarlier, loading, stalled, distance, items, scrollContainer]); + + if (!hasEarlier) return null; + return ( +
{ + focused.current = true; + onFocus?.(event); + }} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) { + focused.current = false; + } + onBlur?.(event); + }} + className={cn( + "flex h-8 shrink-0 items-center justify-center gap-1.5 text-xs text-muted-foreground focus-visible:-outline-offset-2 [&_svg]:size-3.5", + className, + )} + {...props} + > + {stalled ? ( + + ) : ( + (children ?? ( + <> + {loading ? ( + + ) : null} + {loading ? "Loading earlier messages…" : "Earlier messages"} + + )) + )} +
+ ); +}); diff --git a/stories/chat/history-window.stories.tsx b/stories/chat/history-window.stories.tsx new file mode 100644 index 00000000..6de7eb33 --- /dev/null +++ b/stories/chat/history-window.stories.tsx @@ -0,0 +1,143 @@ +import { useState } from "react"; +import type { ReactNode } from "react"; + +import { useHistoryWindow } from "../../src/hooks/use-history-window.js"; +import type { ConversationMessage } from "../../src/lib/conversation.js"; +import { + Conversation, + ConversationContent, + ConversationItem, + ConversationScrollButton, +} from "../../src/ui/conversation.js"; +import { ConversationEarlier } from "../../src/ui/conversation-earlier.js"; +import { longChat, you } from "./samples/conversations.js"; + +export default { title: "Chat / History window" }; + +const PAGE = 40; +const LOAD_DELAY_MS = 900; + +/** A placeholder row: the window renders whatever the host gives it. */ +function Row({ message }: { readonly message: ConversationMessage }) { + const text = message.parts + .flatMap((part) => (part.kind === "text" ? [part.text] : [])) + .join("\n\n"); + const mine = message.author.id === you.id; + return ( +
+ {mine ? null : ( +

+ {message.author.name} +

+ )} +

{text}

+
+ ); +} + +function Frame({ children }: { readonly children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +/** Must sit inside ``: the window follows the reader. */ +function Window({ + messages, + total, + hasEarlier, + onLoadEarlier, +}: { + readonly messages: readonly ConversationMessage[]; + readonly total: number; + readonly hasEarlier?: boolean; + readonly onLoadEarlier?: () => Promise; +}) { + const history = useHistoryWindow({ + items: messages, + hasEarlier, + onLoadEarlier, + }); + return ( + <> +

+ {`Rendering ${history.items.length} of ${messages.length} loaded messages (${total} in all)`} +

+ + + {history.items.map((message) => ( + + + + ))} + + + + ); +} + +/** The 300-message sample held in memory: the latest 120 render, and 60 + * more load as you scroll up without moving what you are reading. Scroll + * back to the latest and the window trims to 120 again. */ +export const LongChat = () => ( + + + + + +); + +/** History paged from a server: 40 messages load at first, and each page + * of 40 more arrives after a delay, with the marker showing the load. */ +export const Paged = () => { + const all = longChat.messages; + const [from, setFrom] = useState(all.length - PAGE); + // The hook asks once until this settles, so one timer is ever pending. + const loadEarlier = () => + new Promise((resolve) => { + setTimeout(() => { + setFrom((current) => Math.max(0, current - PAGE)); + resolve(); + }, LOAD_DELAY_MS); + }); + return ( + + + 0} + onLoadEarlier={loadEarlier} + /> + + + ); +}; + +/** A load that comes back empty: the marker stops asking on scroll and + * offers a button instead. */ +export const Stalled = () => ( + + + Promise.resolve()} + /> + + +);