From 6f2751d8d2f98d502792ec5d8438e4e14c373cf3 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 22 Oct 2026 01:20:10 +0530 Subject: [PATCH 1/7] 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 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..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 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..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()} + /> + + +); From 2ada8c60677664d29315c30dc614792cb99a2bbf Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Tue, 27 Oct 2026 19:25:07 +0545 Subject: [PATCH 2/7] fix(conversation): hold the history window until the view is read An item committed synchronously in the frame the reader scrolled up in, before the conversation's scroll pass, slid or trimmed the window: atBottom and the freeze were still a frame old, so the oldest item was removed while the reader was scrolled up. A new item now commits with nothing removed. A layout effect then trims or slides the window as before, unless the view has moved towards the top since the last scroll snapshot; the next pass then freezes it if the scroll was the reader's. The snapshot subscription moves to a layout effect so the one sent when the container mounts is not missed. A loader that throws now stalls like one that rejects, and its error is reported once instead of escaping the marker's observer callback. The scroll helpers both conversation specs copied now live in one module. --- e2e/conversation-helpers.ts | 74 ++++++++++++++++++++++ e2e/conversation.spec.ts | 80 ++++-------------------- e2e/history-window.spec.ts | 81 +++--------------------- src/hooks/use-history-window.test.tsx | 53 +++++++++++++++- src/hooks/use-history-window.ts | 90 ++++++++++++++++++--------- 5 files changed, 205 insertions(+), 173 deletions(-) create mode 100644 e2e/conversation-helpers.ts 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/history-window.spec.ts b/e2e/history-window.spec.ts index c3b242dc..0e755445 100644 --- a/e2e/history-window.spec.ts +++ b/e2e/history-window.spec.ts @@ -1,7 +1,15 @@ import { expect, test, type Page } from "@playwright/test"; -const container = (page: Page) => - page.locator("[data-slot='conversation-content']"); +import { + atEnd, + container, + firstVisible, + frames, + offsetOf, + settled, + view, + wheel, +} from "./conversation-helpers.js"; const marker = (page: Page) => page.locator("[data-slot='conversation-earlier']"); @@ -10,75 +18,6 @@ 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(); diff --git a/src/hooks/use-history-window.test.tsx b/src/hooks/use-history-window.test.tsx index fae5569d..956fc9bd 100644 --- a/src/hooks/use-history-window.test.tsx +++ b/src/hooks/use-history-window.test.tsx @@ -24,6 +24,8 @@ type Harness = { 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; returnToBottom(): Promise; @@ -110,11 +112,14 @@ function mount( configurable: true, value: 500, }); - /** A scroll to `top`, landing on the next frame. */ - const scrollTo = async (top: number, byReader: boolean) => { + 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(); }; @@ -123,6 +128,7 @@ function mount( commits: () => commits, update: (options) => act(() => render(options)), leaveBottom: () => scrollTo(1_000, true), + leaveBottomUnseen: () => scroll(1_000, true), driftFromBottom: () => scrollTo(1_000, false), returnToBottom: () => scrollTo(99_500, true), setHidden: (next) => { @@ -215,6 +221,24 @@ describe("useHistoryWindow with in-memory history", () => { 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("a removed start item keeps the rest of the window", async () => { const harness = mount({ items: rows(0, 300) }); await harness.leaveBottom(); @@ -371,6 +395,31 @@ describe("useHistoryWindow with server paging", () => { 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 = { diff --git a/src/hooks/use-history-window.ts b/src/hooks/use-history-window.ts index 05fa25b6..6eee44c5 100644 --- a/src/hooks/use-history-window.ts +++ b/src/hooks/use-history-window.ts @@ -1,22 +1,15 @@ -import { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; +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: the latest `windowSize` items, or a pinned item. */ +/** 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 kind: "latest"; readonly through: string | undefined } | { 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; @@ -26,10 +19,8 @@ type EarlierRequest = { 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. */ + /** The `atBottom` seen at the last render, so a change is noticed once. */ readonly atBottom: boolean; - readonly lastId: string | undefined; }; export type UseHistoryWindowOptions = { @@ -78,7 +69,10 @@ function startIndex( previous: readonly { readonly id: string }[], ): number { const latest = Math.max(0, items.length - windowSize); - if (start.kind === "latest") return latest; + if (start.kind === "latest") { + const end = items.findIndex((item) => item.id === start.through); + return end === -1 ? latest : Math.max(0, end + 1 - windowSize); + } 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 @@ -126,7 +120,8 @@ function startIndex( * `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. + * 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 @@ -142,23 +137,27 @@ export function useHistoryWindow({ hasEarlier = false, onLoadEarlier, }: UseHistoryWindowOptions): HistoryWindow { - const { atBottom, captureAnchor, restoreAnchor, subscribeScroll } = - useConversation(); + const { + atBottom, + captureAnchor, + restoreAnchor, + scrollContainer, + subscribeScroll, + } = useConversation(); const lastId = items.at(-1)?.id; const [state, setState] = useState(() => ({ - start: LATEST, + start: { kind: "latest", through: lastId }, 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 }); + 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 }; + setState({ ...state, start, atBottom }); } const index = useMemo( @@ -194,11 +193,38 @@ export function useHistoryWindow({ 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 if the scroll was theirs. + 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 reader scrolls away from the end: freeze the start, so new items no - // longer slide the window over what they are looking at. - useEffect( + // longer slide the window over what they are looking at. 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.byReader) return; if (snapshot.distanceFromEnd <= CONVERSATION_END_THRESHOLD) return; setState((current) => { @@ -243,12 +269,14 @@ export function useHistoryWindow({ request: { first, settled: true }, })); }; - let result: Promise | void; + let result: Promise | void = undefined; try { result = onLoadEarlier(); } catch (error) { - settle(); - throw 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]); From 523fb3fab5f5111a1fd7fb87158794c7620a9ce5 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 16:53:18 +0545 Subject: [PATCH 3/7] fix(conversation): keep the window start when the last id is swapped The window remembers the last item it was trimmed through. When that id vanished in the same update as an append (an optimistic id swapped for the server's, plus the reply), the window slid by one during render. If the reader had just scrolled up, the next frame froze the slid start and the window's first item was gone. A missing trim id now falls back as a missing pin does: to the first item of the last window that still exists. At the bottom, the next commit trims back, as it does after a plain append. --- src/hooks/use-history-window.test.tsx | 25 +++++++++++++++++++++++++ src/hooks/use-history-window.ts | 16 ++++++++-------- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/hooks/use-history-window.test.tsx b/src/hooks/use-history-window.test.tsx index 956fc9bd..0568ebdb 100644 --- a/src/hooks/use-history-window.test.tsx +++ b/src/hooks/use-history-window.test.tsx @@ -239,6 +239,31 @@ describe("useHistoryWindow with in-memory history", () => { 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(); diff --git a/src/hooks/use-history-window.ts b/src/hooks/use-history-window.ts index 6eee44c5..0098c08e 100644 --- a/src/hooks/use-history-window.ts +++ b/src/hooks/use-history-window.ts @@ -68,20 +68,20 @@ function startIndex( windowSize: number, previous: readonly { readonly id: string }[], ): number { - const latest = Math.max(0, items.length - windowSize); if (start.kind === "latest") { const end = items.findIndex((item) => item.id === start.through); - return end === -1 ? latest : Math.max(0, end + 1 - windowSize); + 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; } - 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. + // 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 - ? latest + ? Math.max(0, items.length - windowSize) : items.findIndex((item) => item.id === kept.id); } From da29f7537509cb8e10bfee8aa8b41dfa415a6315 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 19:38:11 +0545 Subject: [PATCH 4/7] fix(conversation): freeze the history window on any move up Only a scroll the reader made froze the window's start. A move the page made, such as a glide to an item, focus or find in page, left the start following the latest items, so the next appended item slid the window and unmounted the item the view had just been moved to. The window now freezes on any scroll snapshot that moved the view towards the top and ended away from the end. Content growing below a view that stays put leaves scrollTop unchanged, so the window still slides then. --- src/hooks/use-history-window.test.tsx | 64 ++++++++++++++++++++++++++- src/hooks/use-history-window.ts | 22 +++++---- 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/src/hooks/use-history-window.test.tsx b/src/hooks/use-history-window.test.tsx index 0568ebdb..288533f2 100644 --- a/src/hooks/use-history-window.test.tsx +++ b/src/hooks/use-history-window.test.tsx @@ -6,6 +6,8 @@ import { Conversation, ConversationContent, ConversationItem, + useConversation, + type ConversationContextValue, } from "../ui/conversation.js"; import { useHistoryWindow, @@ -20,6 +22,7 @@ const rows = (from: number, to: number): Row[] => type Harness = { readonly result: { current: HistoryWindow }; + readonly container: HTMLElement; /** React commits of the host component since mount. */ readonly commits: () => number; update(options: UseHistoryWindowOptions): void; @@ -28,6 +31,10 @@ type Harness = { 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; }; @@ -57,10 +64,12 @@ function mount( }, }; 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; }); @@ -104,9 +113,10 @@ function mount( ); 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, - value: 100_000, + get: () => height, }); Object.defineProperty(scroller, "clientHeight", { configurable: true, @@ -125,11 +135,37 @@ function mount( 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; @@ -192,7 +228,7 @@ describe("useHistoryWindow with in-memory history", () => { expect(result.current.items).toHaveLength(120); }); - test("only the reader's own scroll freezes the window", async () => { + 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. @@ -202,6 +238,30 @@ describe("useHistoryWindow with in-memory history", () => { 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); }); diff --git a/src/hooks/use-history-window.ts b/src/hooks/use-history-window.ts index 0098c08e..2f756f6e 100644 --- a/src/hooks/use-history-window.ts +++ b/src/hooks/use-history-window.ts @@ -87,9 +87,10 @@ function startIndex( /** * 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. + * 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`. * @@ -198,7 +199,7 @@ export function useHistoryWindow({ // `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 if the scroll was theirs. + // pass freezes it. const frameTop = useRef(null); const seenLast = useRef(lastId); useLayoutEffect(() => { @@ -217,16 +218,19 @@ export function useHistoryWindow({ }); }, [lastId, atBottom, scrollContainer]); - // The reader scrolls away from the end: freeze the start, so new items no - // longer slide the window over what they are looking at. Subscribed in a - // layout effect, so the first frame's pass, which reports the view once the - // container mounts, is never missed. + // 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.byReader) return; if (snapshot.distanceFromEnd <= CONVERSATION_END_THRESHOLD) return; + if (!snapshot.byReader && snapshot.delta >= 0) return; setState((current) => { const first = latest.current.items[latest.current.index]?.id; if (current.start.kind !== "latest" || first === undefined) { From d12516d7086f4041298f621f3a4fcef28f11a745 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 19:39:29 +0545 Subject: [PATCH 5/7] fix(conversation): keep the history window's last slice in state The window start's fallback, used when the item it is kept by is gone, read the previous window from a ref during render. The ref was written by a layout effect, so it went stale while Activity hid the host: a start grown while hidden whose item was then deleted fell back to the window from before the host was hidden. The last slice now lives in the hook's state, recorded during render as atBottom already is, and the scroll listener reads the window's first item in the handler rather than in a state updater. --- src/hooks/use-history-window.test.tsx | 11 +++++++ src/hooks/use-history-window.ts | 42 ++++++++++++++++----------- 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/src/hooks/use-history-window.test.tsx b/src/hooks/use-history-window.test.tsx index 288533f2..84dcde09 100644 --- a/src/hooks/use-history-window.test.tsx +++ b/src/hooks/use-history-window.test.tsx @@ -336,6 +336,17 @@ describe("useHistoryWindow with in-memory history", () => { 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", () => { diff --git a/src/hooks/use-history-window.ts b/src/hooks/use-history-window.ts index 2f756f6e..a2323a43 100644 --- a/src/hooks/use-history-window.ts +++ b/src/hooks/use-history-window.ts @@ -16,11 +16,14 @@ type EarlierRequest = { readonly settled: boolean; }; -type WindowState = { +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 = { @@ -120,9 +123,11 @@ function startIndex( * 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. A new item at the bottom commits twice: - * added, then trimmed once the view is read to still be at the end. + * 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 @@ -146,26 +151,29 @@ export function useHistoryWindow({ subscribeScroll, } = useConversation(); const lastId = items.at(-1)?.id; - const [state, setState] = useState(() => ({ + const [state, setState] = useState>(() => ({ start: { kind: "latest", through: lastId }, request: null, atBottom, + shown: [], })); - const previousWindow = useRef([]); + 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 }; - setState({ ...state, start, atBottom }); + next = { ...next, start, atBottom }; } const index = useMemo( - () => startIndex(items, start, windowSize, previousWindow.current), - [items, start, windowSize], + () => 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; @@ -182,7 +190,6 @@ export function useHistoryWindow({ 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. @@ -231,13 +238,14 @@ export function useHistoryWindow({ frameTop.current = snapshot.scrollTop; if (snapshot.distanceFromEnd <= CONVERSATION_END_THRESHOLD) return; if (!snapshot.byReader && snapshot.delta >= 0) 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 } }; - }); + 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], ); From edec7c559f9d9560231a3f00ec723ebdb916f73d Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 19:40:15 +0545 Subject: [PATCH 6/7] docs(conversation): say which failed loads the history window reports The onLoadEarlier doc said the hook never reports a failure. A loader that throws synchronously is reported through reportError, so that it does not escape the marker's observer callback; only a rejection is left to the host. --- src/hooks/use-history-window.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/hooks/use-history-window.ts b/src/hooks/use-history-window.ts index a2323a43..023e39d9 100644 --- a/src/hooks/use-history-window.ts +++ b/src/hooks/use-history-window.ts @@ -43,8 +43,9 @@ export type UseHistoryWindowOptions = { * 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. + * 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; }; From d1672adf3933d8af661173790892c3e4f33727bc Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 19:40:15 +0545 Subject: [PATCH 7/7] fix(conversation): forward ConversationEarlier's ref under React 18 ConversationEarlier read ref from its props, which React 19 passes but React 18 strips, so a ref never reached the marker on React 18, the lower end of the peer range. It is now wrapped in forwardRef. --- src/ui/conversation-earlier.test.tsx | 15 ++++++++++- src/ui/conversation-earlier.tsx | 39 ++++++++++++++++++---------- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/src/ui/conversation-earlier.test.tsx b/src/ui/conversation-earlier.test.tsx index a2be57e0..6a8ee333 100644 --- a/src/ui/conversation-earlier.test.tsx +++ b/src/ui/conversation-earlier.test.tsx @@ -1,4 +1,4 @@ -import { act } from "react"; +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"; @@ -121,6 +121,19 @@ describe("ConversationEarlier", () => { 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( diff --git a/src/ui/conversation-earlier.tsx b/src/ui/conversation-earlier.tsx index cdb5750a..c2774bb5 100644 --- a/src/ui/conversation-earlier.tsx +++ b/src/ui/conversation-earlier.tsx @@ -1,7 +1,13 @@ "use client"; import { LoaderCircle } from "lucide-react"; -import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import { + forwardRef, + useCallback, + useEffect, + useLayoutEffect, + useRef, +} from "react"; import type { ComponentProps } from "react"; import { cn } from "../lib/utils.js"; @@ -35,19 +41,24 @@ export type ConversationEarlierProps = ComponentProps<"div"> & { * 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, +export const ConversationEarlier = forwardRef< + HTMLDivElement, + ConversationEarlierProps +>(function ConversationEarlier( + { + hasEarlier, + loading = false, + stalled = false, + onReachStart, + distance = 1200, + className, + children, + onFocus, + onBlur, + ...props + }, ref, - onFocus, - onBlur, - ...props -}: ConversationEarlierProps) { +) { const { items, scrollContainer } = useConversation(); const markerRef = useRef(null); const reach = useRef(onReachStart); @@ -170,4 +181,4 @@ export function ConversationEarlier({ )} ); -} +});