From 628cf5ecb5ceadae797d9746b400f98c2acd115e Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 22 Oct 2026 01:20:10 +0530 Subject: [PATCH] feat(conversation): add useHistoryWindow and ConversationEarlier useHistoryWindow keeps a window of the latest items, adds earlier ones as the reader nears the top, freezes while the reader has scrolled away and trims back once they return to the end. It works with history the host pages in asynchronously, and the item in view stays put within a pixel as earlier items load. ConversationEarlier marks the top with a loading state and a retry when a page brings nothing. The doc comment records why this windows rather than virtualizes, and its ceiling. --- e2e/fixtures.tsx | 121 +++++++ e2e/history-window.spec.ts | 257 ++++++++++++++ src/hooks/use-history-window.test.tsx | 451 ++++++++++++++++++++++++ src/hooks/use-history-window.ts | 263 ++++++++++++++ src/index.ts | 2 + src/ui/conversation-earlier.test.tsx | 203 +++++++++++ src/ui/conversation-earlier.tsx | 173 +++++++++ stories/chat/history-window.stories.tsx | 143 ++++++++ 8 files changed, 1613 insertions(+) create mode 100644 e2e/history-window.spec.ts create mode 100644 src/hooks/use-history-window.test.tsx create mode 100644 src/hooks/use-history-window.ts create mode 100644 src/ui/conversation-earlier.test.tsx create mode 100644 src/ui/conversation-earlier.tsx create mode 100644 stories/chat/history-window.stories.tsx diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index becd760f..e31c7738 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -58,6 +58,8 @@ import { MessageContent, MessageFold, MessageFooter, + ConversationEarlier, + useHistoryWindow, } from "@corbits/react-ui"; import { CommandPalette, @@ -939,6 +941,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": () => , @@ -985,4 +1097,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..c3b242dc --- /dev/null +++ b/e2e/history-window.spec.ts @@ -0,0 +1,257 @@ +import { expect, test, type Page } from "@playwright/test"; + +const container = (page: Page) => + page.locator("[data-slot='conversation-content']"); + +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 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); +}; + +/** Two frames: one for resize callbacks, one for any correction to land. */ +const frames = (page: Page) => + page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); + +/** 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 ""; + }); + +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..fae5569d --- /dev/null +++ b/src/hooks/use-history-window.test.tsx @@ -0,0 +1,451 @@ +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, +} 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 }; + /** React commits of the host component since mount. */ + readonly commits: () => number; + update(options: UseHistoryWindowOptions): void; + leaveBottom(): Promise; + /** The view moves away from the end with no reader input (layout, a glide). */ + driftFromBottom(): 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; + + function Host({ options }: { options: UseHistoryWindowOptions }) { + const history = useHistoryWindow(options); + result.current = history; + 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. + Object.defineProperty(scroller, "scrollHeight", { + configurable: true, + value: 100_000, + }); + Object.defineProperty(scroller, "clientHeight", { + configurable: true, + value: 500, + }); + /** A scroll to `top`, landing on the next frame. */ + const scrollTo = async (top: number, byReader: boolean) => { + if (byReader) scroller.dispatchEvent(new WheelEvent("wheel")); + scroller.scrollTop = top; + scroller.dispatchEvent(new Event("scroll")); + await nextFrame(); + }; + + return { + result, + commits: () => commits, + update: (options) => act(() => render(options)), + leaveBottom: () => scrollTo(1_000, true), + driftFromBottom: () => scrollTo(1_000, false), + 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("only the reader's own scroll freezes the window", 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("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("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); + }); +}); + +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 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..05fa25b6 --- /dev/null +++ b/src/hooks/use-history-window.ts @@ -0,0 +1,263 @@ +import { + useCallback, + useEffect, + 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: the latest `windowSize` items, or a pinned item. */ +type WindowStart = + | { readonly kind: "latest" } + | { readonly kind: "item"; readonly id: string }; + +const LATEST: WindowStart = { kind: "latest" }; + +/** 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` and last item id seen at the last render, so a change + * in either is noticed once. */ + readonly atBottom: boolean; + readonly lastId: string | undefined; +}; + +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, or a rejection) leaves the window `stalled`; report failures 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 { + const latest = Math.max(0, items.length - windowSize); + if (start.kind === "latest") return latest; + const at = items.findIndex((item) => item.id === start.id); + if (at !== -1) return at; + // The pinned item 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. + const ids = new Set(items.map((item) => item.id)); + const kept = previous.find((item) => ids.has(item.id)); + return kept === undefined + ? latest + : 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. While + * the reader is scrolled up 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, and scrolling re-renders the host only when the reader + * leaves or reaches the bottom. + * + * 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, subscribeScroll } = + useConversation(); + const lastId = items.at(-1)?.id; + const [state, setState] = useState(() => ({ + start: LATEST, + request: null, + atBottom, + lastId, + })); + const previousWindow = useRef([]); + + let start = state.start; + if (state.atBottom !== atBottom || state.lastId !== lastId) { + // Back at the bottom, or a new item while there: trim back. The + // conversation's anchor is current from the reader's last scroll. + if (atBottom) start = LATEST; + setState({ ...state, start, atBottom, lastId }); + } + + const index = useMemo( + () => startIndex(items, start, windowSize, previousWindow.current), + [items, start, windowSize], + ); + const shown = useMemo(() => items.slice(index), [items, index]); + 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 }; + previousWindow.current = shown; + }); + + // 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]); + + // The reader scrolls away from the end: freeze the start, so new items no + // longer slide the window over what they are looking at. + useEffect( + () => + subscribeScroll((snapshot) => { + if (!snapshot.byReader) return; + if (snapshot.distanceFromEnd <= CONVERSATION_END_THRESHOLD) return; + setState((current) => { + const first = latest.current.items[latest.current.index]?.id; + if (current.start.kind !== "latest" || first === undefined) { + return current; + } + return { ...current, start: { kind: "item", id: first } }; + }); + }), + [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; + try { + result = onLoadEarlier(); + } catch (error) { + settle(); + throw 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 8ff9fa6e..e47d7140 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,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"; @@ -147,3 +148,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..a2be57e0 --- /dev/null +++ b/src/ui/conversation-earlier.test.tsx @@ -0,0 +1,203 @@ +import { act } 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("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..cdb5750a --- /dev/null +++ b/src/ui/conversation-earlier.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { LoaderCircle } from "lucide-react"; +import { 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 function ConversationEarlier({ + hasEarlier, + loading = false, + stalled = false, + onReachStart, + distance = 1200, + className, + children, + ref, + onFocus, + onBlur, + ...props +}: ConversationEarlierProps) { + 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()} + /> + + +);