feat(site/src/pages/AgentsPage/components/ChatVirtualList): add true windowing with capture/restore scroll anchor

Recreate the @pierre/diffs windowing model directly: render only the
items within the viewport plus a 1000px overscan, reserve the rest with
top/bottom spacers sized from a per-id measured height cache, and recycle
offscreen items. A single layout effect owns scrollTop, restoring a
captured top-of-viewport anchor on content changes and re-capturing on
pure scrolls. Validated by Storybook and unit tests; not wired in.

Generated by Coder Agent on behalf of Danielle Maywood.
This commit is contained in:
Danielle Maywood
2026-05-30 20:38:50 +00:00
parent e5daef492c
commit 3ffd6e788b
8 changed files with 898 additions and 125 deletions
@@ -1,37 +1,75 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { type FC, useRef, useState } from "react";
import { expect, userEvent, waitFor, within } from "storybook/test";
import { ChatVirtualList } from "./ChatVirtualList";
import { ChatVirtualList, type VirtualItem } from "./ChatVirtualList";
import type { MessageKind } from "./heightCache";
type Item = { id: number; height: number };
type DemoItem = { id: string; kind: MessageKind; height: number };
const StoryHarness: FC<{ hasMoreMessages?: boolean }> = ({
hasMoreMessages = false,
}) => {
const makeItems = (count: number, height = 220): DemoItem[] =>
Array.from({ length: count }, (_, i) => ({
id: `m-${i}`,
kind: "assistant" as MessageKind,
height,
}));
const StoryHarness: FC<{
initialCount?: number;
hasMoreMessages?: boolean;
itemHeight?: number;
}> = ({ initialCount = 40, hasMoreMessages = false, itemHeight = 220 }) => {
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const scrollToBottomRef = useRef<(() => void) | null>(null);
const nextId = useRef(40);
const olderId = useRef(-1);
const [items, setItems] = useState<Item[]>(() =>
Array.from({ length: 40 }, (_, i) => ({ id: i, height: 120 })),
const [items, setItems] = useState<DemoItem[]>(() =>
makeItems(initialCount, itemHeight),
);
const [fetchCount, setFetchCount] = useState(0);
// Derive new ids purely from the previous list so the updater stays pure.
// React StrictMode double-invokes updaters; a mutable counter would advance
// twice and make the appended/prepended ids unpredictable.
const append = () =>
setItems((prev) => [...prev, { id: nextId.current++, height: 200 }]);
setItems((prev) => {
const nextIndex =
prev.reduce((max, it) => {
const n = it.id.startsWith("m-") ? Number(it.id.slice(2)) : -1;
return Number.isNaN(n) ? max : Math.max(max, n);
}, -1) + 1;
return [
...prev,
{ id: `m-${nextIndex}`, kind: "assistant", height: itemHeight },
];
});
const prepend = () =>
setItems((prev) => [
...Array.from({ length: 5 }, () => ({
id: olderId.current--,
setItems((prev) => {
const minOld = prev.reduce((min, it) => {
const n = it.id.startsWith("old-") ? Number(it.id.slice(4)) : 0;
return Number.isNaN(n) ? min : Math.min(min, n);
}, 0);
const older = Array.from({ length: 5 }, (_, i) => ({
id: `old-${minOld - 1 - i}`,
kind: "user" as MessageKind,
height: 120,
})),
...prev,
]);
}));
return [...older, ...prev];
});
const growFive = () =>
setItems((prev) =>
prev.map((it) => (it.id === 5 ? { ...it, height: 320 } : it)),
prev.map((it) =>
it.id === "m-5" ? { ...it, height: it.height + 160 } : it,
),
);
const byId = new Map(items.map((it) => [it.id, it]));
const renderItem = (item: VirtualItem) => {
const demo = byId.get(item.id);
return (
<div data-testid={item.id} style={{ height: demo?.height ?? 120 }}>
{item.id}
</div>
);
};
return (
<div className="flex h-[480px] w-[640px] flex-col">
<div className="flex gap-1">
@@ -47,23 +85,14 @@ const StoryHarness: FC<{ hasMoreMessages?: boolean }> = ({
<span data-testid="fetch-count">{fetchCount}</span>
</div>
<ChatVirtualList
items={items}
renderItem={renderItem}
scrollContainerRef={scrollContainerRef}
scrollToBottomRef={scrollToBottomRef}
isFetchingMoreMessages={false}
hasMoreMessages={hasMoreMessages}
onFetchMoreMessages={() => setFetchCount((count) => count + 1)}
messageCount={items.length}
>
{items.map((it) => (
<div
key={it.id}
data-testid={`msg-${it.id}`}
style={{ height: it.height }}
>
message {it.id}
</div>
))}
</ChatVirtualList>
/>
</div>
);
};
@@ -76,45 +105,80 @@ export default meta;
type Story = StoryObj<typeof ChatVirtualList>;
const offsetFromScrollerTop = (
scroller: HTMLElement,
el: HTMLElement,
): number =>
el.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
const distanceFromBottom = (scroller: HTMLElement): number =>
scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
// visibleAnchorId returns the id of the first rendered item intersecting the
// viewport top, picked dynamically so assertions do not depend on which items
// the window chose to render.
const visibleAnchorId = (
canvasElement: HTMLElement,
scroller: HTMLElement,
): string => {
const top = scroller.getBoundingClientRect().top;
for (const el of canvasElement.querySelectorAll("[data-chat-item-id]")) {
if (el.getBoundingClientRect().bottom > top + 5) {
const id = el.getAttribute("data-chat-item-id");
if (id) {
return id;
}
}
}
throw new Error("no visible item");
};
const offsetOfId = (
canvasElement: HTMLElement,
scroller: HTMLElement,
id: string,
): number => {
const el = canvasElement.querySelector(`[data-chat-item-id="${id}"]`);
if (!el) {
return Number.NaN;
}
return el.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
};
// settleWindow waits until the window has re-rendered around the current scroll
// position, i.e. a real item sits at the viewport top. The window recompute is
// async, so picking a reference item before settling races against it and may
// observe a stale window from the previous scroll position.
const settleWindow = async (
canvasElement: HTMLElement,
scroller: HTMLElement,
): Promise<void> => {
await waitFor(() => {
const id = visibleAnchorId(canvasElement, scroller);
expect(Math.abs(offsetOfId(canvasElement, scroller, id))).toBeLessThan(400);
});
};
export const RendersAndOverflows: Story = {
render: () => <StoryHarness />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scroller = canvas.getByTestId("scroll-container");
await expect(scroller.scrollHeight).toBeGreaterThan(scroller.clientHeight);
await waitFor(() =>
expect(scroller.scrollHeight).toBeGreaterThan(scroller.clientHeight),
);
},
};
export const AnchorStaysStableOnAboveViewportGrowth: Story = {
render: () => <StoryHarness />,
export const RendersOnlyWindow: Story = {
render: () => <StoryHarness initialCount={200} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scroller = canvas.getByTestId("scroll-container");
scroller.scrollTop = 1500;
scroller.dispatchEvent(new Event("scroll"));
await waitFor(() => expect(scroller.scrollTop).toBe(1500));
const before = offsetFromScrollerTop(
scroller,
canvas.getByTestId("msg-15"),
);
await userEvent.click(canvas.getByTestId("grow-5"));
await waitFor(() =>
expect(
Math.abs(
offsetFromScrollerTop(scroller, canvas.getByTestId("msg-15")) -
before,
),
).toBeLessThanOrEqual(2),
expect(distanceFromBottom(scroller)).toBeLessThanOrEqual(16),
);
// Only the near-viewport window is rendered, not all 200 items, and a
// far-above item is not mounted once the window has converged.
await waitFor(() => expect(canvas.queryByTestId("m-2")).toBeNull());
const rendered = canvasElement.querySelectorAll("[data-chat-item]");
await expect(rendered.length).toBeLessThanOrEqual(40);
const topSpacer = canvasElement.querySelector("[data-virtual-spacer]");
await expect((topSpacer as HTMLElement).style.height).not.toBe("0px");
},
};
@@ -138,6 +202,9 @@ export const DoesNotYankWhenScrolledUp: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scroller = canvas.getByTestId("scroll-container");
await waitFor(() =>
expect(distanceFromBottom(scroller)).toBeLessThanOrEqual(16),
);
scroller.scrollTop = 800;
scroller.dispatchEvent(new Event("scroll"));
await waitFor(() => expect(scroller.scrollTop).toBe(800));
@@ -153,6 +220,9 @@ export const FetchesOlderNearTop: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scroller = canvas.getByTestId("scroll-container");
await waitFor(() =>
expect(distanceFromBottom(scroller)).toBeLessThanOrEqual(16),
);
scroller.scrollTop = 0;
scroller.dispatchEvent(new Event("scroll"));
await waitFor(() =>
@@ -163,35 +233,14 @@ export const FetchesOlderNearTop: Story = {
},
};
export const PrependKeepsViewportStable: Story = {
render: () => <StoryHarness />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scroller = canvas.getByTestId("scroll-container");
scroller.scrollTop = 1500;
scroller.dispatchEvent(new Event("scroll"));
await waitFor(() => expect(scroller.scrollTop).toBe(1500));
const before = offsetFromScrollerTop(
scroller,
canvas.getByTestId("msg-20"),
);
await userEvent.click(canvas.getByTestId("prepend"));
await waitFor(() =>
expect(
Math.abs(
offsetFromScrollerTop(scroller, canvas.getByTestId("msg-20")) -
before,
),
).toBeLessThanOrEqual(2),
);
},
};
export const ScrollToBottomButtonWorks: Story = {
render: () => <StoryHarness />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scroller = canvas.getByTestId("scroll-container");
await waitFor(() =>
expect(distanceFromBottom(scroller)).toBeLessThanOrEqual(16),
);
scroller.scrollTop = 100;
scroller.dispatchEvent(new Event("scroll"));
const button = await canvas.findByRole("button", {
@@ -203,3 +252,138 @@ export const ScrollToBottomButtonWorks: Story = {
);
},
};
export const NoJumpOnPrepend: Story = {
render: () => <StoryHarness initialCount={200} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scroller = canvas.getByTestId("scroll-container");
await waitFor(() =>
expect(distanceFromBottom(scroller)).toBeLessThanOrEqual(16),
);
scroller.scrollTop = 20000;
scroller.dispatchEvent(new Event("scroll"));
await waitFor(() => expect(scroller.scrollTop).toBe(20000));
await settleWindow(canvasElement, scroller);
const ref = visibleAnchorId(canvasElement, scroller);
const before = offsetOfId(canvasElement, scroller, ref);
await userEvent.click(canvas.getByTestId("prepend"));
await waitFor(() =>
expect(
Math.abs(offsetOfId(canvasElement, scroller, ref) - before),
).toBeLessThanOrEqual(3),
);
},
};
export const StreamingGrowthAboveViewportNoJump: Story = {
render: () => <StoryHarness initialCount={200} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scroller = canvas.getByTestId("scroll-container");
await waitFor(() =>
expect(distanceFromBottom(scroller)).toBeLessThanOrEqual(16),
);
// Put m-5 (offset 1100) into the top overscan, above the viewport top.
scroller.scrollTop = 1800;
scroller.dispatchEvent(new Event("scroll"));
await waitFor(() => expect(scroller.scrollTop).toBe(1800));
await settleWindow(canvasElement, scroller);
const ref = visibleAnchorId(canvasElement, scroller);
const before = offsetOfId(canvasElement, scroller, ref);
// m-5 grows above the viewport; the visible reference must not move.
await userEvent.click(canvas.getByTestId("grow-5"));
await waitFor(() =>
expect(
Math.abs(offsetOfId(canvasElement, scroller, ref) - before),
).toBeLessThanOrEqual(3),
);
},
};
export const NoJumpScrollingThroughWrongEstimates: Story = {
// Real height 120 differs sharply from the assistant seed 220, so items
// entering the top of the window reconcile estimate to measured. Scrolling up
// by a delta must move a tracked item by exactly that delta.
render: () => <StoryHarness initialCount={200} itemHeight={120} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scroller = canvas.getByTestId("scroll-container");
await waitFor(() =>
expect(distanceFromBottom(scroller)).toBeLessThanOrEqual(16),
);
scroller.scrollTop = 10000;
scroller.dispatchEvent(new Event("scroll"));
await waitFor(() => expect(scroller.scrollTop).toBe(10000));
await settleWindow(canvasElement, scroller);
const ref = visibleAnchorId(canvasElement, scroller);
const before = offsetOfId(canvasElement, scroller, ref);
scroller.scrollTop = 9500;
scroller.dispatchEvent(new Event("scroll"));
await waitFor(() =>
expect(
Math.abs(offsetOfId(canvasElement, scroller, ref) - (before + 500)),
).toBeLessThanOrEqual(5),
);
},
};
export const OnlyWindowMounts: Story = {
render: () => <StoryHarness initialCount={1000} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scroller = canvas.getByTestId("scroll-container");
await waitFor(() =>
expect(distanceFromBottom(scroller)).toBeLessThanOrEqual(16),
);
// The newest item is mounted at the bottom.
await waitFor(() => expect(canvas.queryByTestId("m-999")).not.toBeNull());
// DOM node count stays bounded by the window, independent of the 1000-item
// list size: a render-all implementation would mount far more than this.
const rendered = canvasElement.querySelectorAll("[data-chat-item]");
await expect(rendered.length).toBeLessThanOrEqual(40);
},
};
export const AppendKeepsPinned: Story = {
render: () => <StoryHarness initialCount={1000} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scroller = canvas.getByTestId("scroll-container");
await waitFor(() =>
expect(distanceFromBottom(scroller)).toBeLessThanOrEqual(16),
);
await userEvent.click(canvas.getByTestId("append"));
// The appended item mounts and the scroller stays pinned to the bottom.
await waitFor(() => expect(canvas.queryByTestId("m-1000")).not.toBeNull());
await waitFor(() =>
expect(distanceFromBottom(scroller)).toBeLessThanOrEqual(16),
);
},
};
export const ScrollRecyclesOffscreen: Story = {
render: () => <StoryHarness initialCount={200} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scroller = canvas.getByTestId("scroll-container");
await waitFor(() =>
expect(distanceFromBottom(scroller)).toBeLessThanOrEqual(16),
);
// Bring m-100 into the window.
scroller.scrollTop = 22000;
scroller.dispatchEvent(new Event("scroll"));
await waitFor(() => expect(canvas.queryByTestId("m-100")).not.toBeNull());
// Scroll far below m-100 so it falls outside the overscan window.
scroller.scrollTop = 40000;
scroller.dispatchEvent(new Event("scroll"));
// The offscreen row leaves the DOM (true recycling) and the top spacer
// grows to reserve the space of every item now scrolled above the window,
// including m-100's slot at offset 22000.
await waitFor(() => expect(canvas.queryByTestId("m-100")).toBeNull());
const topSpacer = canvasElement.querySelector("[data-virtual-spacer]");
await expect(
Number.parseInt((topSpacer as HTMLElement).style.height, 10),
).toBeGreaterThan(22000);
},
};
@@ -6,34 +6,94 @@ import {
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import {
createHeightCache,
type HeightCache,
type MessageKind,
} from "./heightCache";
import { ScrollToBottomButton } from "./ScrollToBottomButton";
import { useScrollAnchor } from "./useScrollAnchor";
import { computeWindow, cumulativeOffsets } from "./windowMath";
export type VirtualItem = { id: string; kind: MessageKind };
// Render this many pixels above and below the viewport, matching the
// @pierre/diffs Virtualizer overscan so fast scrolls do not blank.
const VIRTUAL_OVERSCAN = 1000;
const LOAD_MORE_MARGIN = "600px 0px 0px 0px";
type ChatVirtualListProps = {
items: ReadonlyArray<VirtualItem>;
renderItem: (item: VirtualItem) => ReactNode;
scrollContainerRef: RefObject<HTMLDivElement | null>;
scrollToBottomRef: RefObject<(() => void) | null>;
isFetchingMoreMessages: boolean;
hasMoreMessages: boolean;
onFetchMoreMessages: () => void;
messageCount: number;
children: ReactNode;
};
// The cached height sizes this item's spacer once it scrolls out of the
// window, so leaving the window is layout-neutral.
const MeasuredItem: FC<{
item: VirtualItem;
renderItem: (item: VirtualItem) => ReactNode;
cache: HeightCache;
onMeasured: () => void;
}> = ({ item, renderItem, cache, onMeasured }) => {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) {
return;
}
const observer = new ResizeObserver(() => {
const height = el.getBoundingClientRect().height;
if (height !== cache.get(item.id)) {
cache.record(item.id, item.kind, height);
onMeasured();
}
});
observer.observe(el);
return () => observer.disconnect();
}, [item, cache, onMeasured]);
return (
<div ref={ref} data-chat-item="" data-chat-item-id={item.id}>
{renderItem(item)}
</div>
);
};
export const ChatVirtualList: FC<ChatVirtualListProps> = ({
items,
renderItem,
scrollContainerRef,
scrollToBottomRef,
isFetchingMoreMessages,
hasMoreMessages,
onFetchMoreMessages,
messageCount,
children,
}) => {
const { scrollerRef, contentRef, atBottom, scrollToBottom, maintainPin } =
useScrollAnchor();
const {
scrollerRef,
contentRef,
atBottom,
scrollToBottom,
captureAnchor,
restoreAnchor,
} = useScrollAnchor();
const topSentinelRef = useRef<HTMLDivElement | null>(null);
// Lazy useState initializer keeps one cache instance for the component's life
// without reading a ref during render, which the React Compiler forbids.
const [cache] = useState<HeightCache>(() => createHeightCache());
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
const [cacheVersion, setCacheVersion] = useState(0);
const bumpCacheVersion = useCallback(() => {
setCacheVersion((value) => value + 1);
}, []);
const setScroller = useCallback(
(element: HTMLDivElement | null) => {
@@ -44,10 +104,102 @@ export const ChatVirtualList: FC<ChatVirtualListProps> = ({
[scrollerRef, scrollContainerRef, scrollToBottomRef, scrollToBottom],
);
// biome-ignore lint/correctness/useExhaustiveDependencies(messageCount): messageCount is an intentional trigger so a new message pins to the bottom; maintainPin reads refs and must re-run when the count changes.
const heights = items.map((item) => cache.estimate(item.id, item.kind));
const offsets = cumulativeOffsets(heights);
const { start, end, topPad, bottomPad } = computeWindow({
offsets,
scrollTop,
// Until the scroller is measured, fall back to a 1px viewport so the first
// window stays small (overscan only) instead of mounting the whole list.
viewportHeight: viewportHeight || 1,
overscan: VIRTUAL_OVERSCAN,
});
const visible = end >= start ? items.slice(start, end + 1) : [];
// Mirror the live scroll position into state so the window recomputes as the
// reader scrolls. The anchor is captured by the layout effect below, after the
// new window commits, never here. A teleport scroll therefore records a real
// rendered item instead of a stale one from the previous window.
useEffect(() => {
const scroller = scrollerRef.current;
if (!scroller) {
return;
}
let frame = 0;
const onScroll = () => {
if (frame) {
return;
}
frame = requestAnimationFrame(() => {
frame = 0;
setScrollTop(scroller.scrollTop);
});
};
scroller.addEventListener("scroll", onScroll, { passive: true });
return () => {
scroller.removeEventListener("scroll", onScroll);
if (frame) {
cancelAnimationFrame(frame);
}
};
}, [scrollerRef]);
// A resize counts as a content change below, so the anchor (or bottom pin)
// is preserved across it.
useEffect(() => {
const scroller = scrollerRef.current;
if (!scroller) {
return;
}
const observer = new ResizeObserver(() => {
setViewportHeight(scroller.clientHeight);
});
observer.observe(scroller);
setViewportHeight(scroller.clientHeight);
return () => observer.disconnect();
}, [scrollerRef]);
// Single scrollTop owner. After every commit, classify the change by diffing
// the committed inputs against the previous commit:
// - content changed (items, measured heights, or viewport): the DOM shifted
// under the reader, so restore the captured anchor (or re-pin to the
// bottom), then re-capture at the settled position.
// - only scrollTop changed (a deliberate scroll): just re-capture; restoring
// here would fight the scroll.
// restore writes scroller.scrollTop, which the scroll listener turns into the
// next scrollTop state, so a correction converges across frames.
const prevCommitRef = useRef<{
scrollTop: number;
items: ReadonlyArray<VirtualItem>;
cacheVersion: number;
viewportHeight: number;
} | null>(null);
useLayoutEffect(() => {
maintainPin();
}, [messageCount, maintainPin]);
const prev = prevCommitRef.current;
prevCommitRef.current = { scrollTop, items, cacheVersion, viewportHeight };
if (!prev) {
restoreAnchor();
captureAnchor();
return;
}
const contentChanged =
items !== prev.items ||
cacheVersion !== prev.cacheVersion ||
viewportHeight !== prev.viewportHeight;
if (contentChanged) {
restoreAnchor();
captureAnchor();
} else if (scrollTop !== prev.scrollTop) {
captureAnchor();
}
}, [
scrollTop,
viewportHeight,
items,
cacheVersion,
restoreAnchor,
captureAnchor,
]);
useEffect(() => {
const sentinel = topSentinelRef.current;
@@ -85,7 +237,17 @@ export const ChatVirtualList: FC<ChatVirtualListProps> = ({
>
<div ref={contentRef} className="flex flex-col">
<div ref={topSentinelRef} aria-hidden className="h-0" />
{children}
<div data-virtual-spacer="" style={{ height: topPad }} />
{visible.map((item) => (
<MeasuredItem
key={item.id}
item={item}
renderItem={renderItem}
cache={cache}
onMeasured={bumpCacheVersion}
/>
))}
<div data-virtual-spacer="" style={{ height: bottomPad }} />
</div>
</div>
<ScrollToBottomButton
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { createHeightCache } from "./heightCache";
describe("createHeightCache", () => {
it("uses a provided seed when a kind has no samples", () => {
const cache = createHeightCache({ assistant: 200 });
expect(cache.estimate("a1", "assistant")).toBe(200);
});
it("falls back to a built-in seed when none is provided", () => {
const cache = createHeightCache();
expect(cache.estimate("u1", "user")).toBe(80);
});
it("averages recorded heights per kind", () => {
const cache = createHeightCache();
cache.record("a1", "assistant", 100);
cache.record("a2", "assistant", 300);
expect(cache.estimate("a3", "assistant")).toBe(200);
});
it("returns the measured height for a known id via get and estimate", () => {
const cache = createHeightCache();
cache.record("a1", "assistant", 512);
expect(cache.get("a1")).toBe(512);
expect(cache.estimate("a1", "assistant")).toBe(512);
});
it("updates the average when an id is re-recorded, without double counting", () => {
const cache = createHeightCache();
cache.record("a1", "assistant", 100);
cache.record("a1", "assistant", 200);
expect(cache.estimate("a2", "assistant")).toBe(200);
});
it("returns undefined from get for an unknown id", () => {
const cache = createHeightCache();
expect(cache.get("nope")).toBeUndefined();
});
it("keeps averages per kind independent", () => {
const cache = createHeightCache();
cache.record("u1", "user", 60);
cache.record("a1", "assistant", 400);
expect(cache.estimate("u2", "user")).toBe(60);
expect(cache.estimate("a2", "assistant")).toBe(400);
});
});
@@ -0,0 +1,62 @@
// Type-aware height model for the windowing renderer. Pure mutable state, read
// on render and updated from measurement. No DOM or React here.
export type MessageKind = "user" | "assistant" | "tool" | "diff" | "other";
export type HeightCache = {
// get returns the measured height for an id, or undefined if never measured.
get(id: string): number | undefined;
// estimate returns the measured height if known, else the running average for
// the kind, else the seed. Used to size not-yet-measured items.
estimate(id: string, kind: MessageKind): number;
// record stores an id's measured height and folds it into the kind average.
record(id: string, kind: MessageKind, height: number): void;
};
// Seeds only affect the first paint of a kind before any of its messages have
// been measured. They are intentionally rough.
const DEFAULT_SEEDS: Record<MessageKind, number> = {
user: 80,
assistant: 220,
tool: 140,
diff: 320,
other: 160,
};
export function createHeightCache(
seeds?: Partial<Record<MessageKind, number>>,
): HeightCache {
const seed: Record<MessageKind, number> = { ...DEFAULT_SEEDS, ...seeds };
const measured = new Map<string, { kind: MessageKind; height: number }>();
const totals = new Map<MessageKind, { sum: number; count: number }>();
const kindAverage = (kind: MessageKind): number | undefined => {
const total = totals.get(kind);
if (!total || total.count === 0) {
return undefined;
}
return total.sum / total.count;
};
return {
get(id) {
return measured.get(id)?.height;
},
estimate(id, kind) {
return measured.get(id)?.height ?? kindAverage(kind) ?? seed[kind];
},
record(id, kind, height) {
const previous = measured.get(id);
const total = totals.get(kind) ?? { sum: 0, count: 0 };
// Replace the previous sample so re-measuring never double counts.
if (previous && previous.kind === kind) {
total.sum += height - previous.height;
} else {
total.sum += height;
total.count += 1;
}
totals.set(kind, total);
measured.set(id, { kind, height });
},
};
}
@@ -0,0 +1,129 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { type FC, useLayoutEffect, useState } from "react";
import { expect, userEvent, waitFor, within } from "storybook/test";
import { useScrollAnchor } from "./useScrollAnchor";
// Exercises the capture/restore primitive in isolation, without the windowing
// renderer, so a failure here points at the anchor hook rather than the layout
// math.
const ROWS = 40;
const ROW_HEIGHT = 100;
const SPACER_HEIGHT = 50;
const AnchorHarness: FC = () => {
const { scrollerRef, contentRef, captureAnchor, restoreAnchor } =
useScrollAnchor();
const [heights, setHeights] = useState<Record<number, number>>({});
const [version, setVersion] = useState(0);
const mutate = (changes: Record<number, number>) => {
// Record the item at the viewport top before the heights change, then
// restore it once the mutation commits. This mirrors how the container
// brackets a content mutation around the layout effect.
captureAnchor();
setHeights((prev) => ({ ...prev, ...changes }));
setVersion((value) => value + 1);
};
// biome-ignore lint/correctness/useExhaustiveDependencies(version): version is the layout-mutation trigger; restore reads the DOM and must run after each mutation commits.
useLayoutEffect(() => {
restoreAnchor();
}, [version, restoreAnchor]);
return (
<div className="flex h-[400px] w-[600px] flex-col">
<div className="flex gap-1">
<button
type="button"
data-testid="grow-above"
onClick={() => mutate({ 5: 300 })}
>
grow-above
</button>
<button
type="button"
data-testid="zero-net"
onClick={() => mutate({ 5: 200, 35: 0 })}
>
zero-net
</button>
</div>
<div
ref={scrollerRef}
data-testid="scroll-container"
className="flex min-h-0 flex-1 flex-col overflow-y-auto [overflow-anchor:none]"
>
<div ref={contentRef} className="flex flex-col">
<div data-virtual-spacer="" style={{ height: SPACER_HEIGHT }} />
{Array.from({ length: ROWS }, (_, i) => (
<div
key={i}
data-chat-item=""
data-chat-item-id={`row-${i}`}
data-testid={`row-${i}`}
style={{ height: heights[i] ?? ROW_HEIGHT }}
>
row {i}
</div>
))}
</div>
</div>
</div>
);
};
const meta: Meta<typeof AnchorHarness> = {
title: "pages/AgentsPage/ChatVirtualList/useScrollAnchor",
component: AnchorHarness,
};
export default meta;
type Story = StoryObj<typeof AnchorHarness>;
const offsetFromTop = (scroller: HTMLElement, el: HTMLElement): number =>
el.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
// Scroll so row 20 sits at the viewport top: spacer + 20 rows.
const SCROLL_TO_ROW_20 = SPACER_HEIGHT + 20 * ROW_HEIGHT;
export const AnchorSurvivesAboveMutation: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scroller = canvas.getByTestId("scroll-container");
scroller.scrollTop = SCROLL_TO_ROW_20;
scroller.dispatchEvent(new Event("scroll"));
await waitFor(() => expect(scroller.scrollTop).toBe(SCROLL_TO_ROW_20));
const before = offsetFromTop(scroller, canvas.getByTestId("row-20"));
await userEvent.click(canvas.getByTestId("grow-above"));
await waitFor(() =>
expect(
Math.abs(
offsetFromTop(scroller, canvas.getByTestId("row-20")) - before,
),
).toBeLessThanOrEqual(2),
);
},
};
export const AnchorSurvivesZeroNetMutation: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scroller = canvas.getByTestId("scroll-container");
scroller.scrollTop = SCROLL_TO_ROW_20;
scroller.dispatchEvent(new Event("scroll"));
await waitFor(() => expect(scroller.scrollTop).toBe(SCROLL_TO_ROW_20));
const before = offsetFromTop(scroller, canvas.getByTestId("row-20"));
// Grows a row above the anchor and shrinks a row below it by the same
// amount: total height is unchanged, so a content ResizeObserver never
// fires, yet the anchor moved. Only capture/restore corrects this.
await userEvent.click(canvas.getByTestId("zero-net"));
await waitFor(() =>
expect(
Math.abs(
offsetFromTop(scroller, canvas.getByTestId("row-20")) - before,
),
).toBeLessThanOrEqual(2),
);
},
};
@@ -7,34 +7,65 @@ import {
} from "react";
import { correctedScrollTop, isAtBottom } from "./anchorMath";
type Anchor = { el: HTMLElement; offset: number };
// The anchor is keyed by a stable item id so it survives windowing remounts.
// The element ref is a fast path; if it has been unmounted we re-find by id.
type Anchor = { id: string | null; el: HTMLElement; offset: number };
// findTopAnchor returns the first child intersecting the viewport top, together
// with its current offset from the scroller top. That child is what we keep
// fixed when content above it changes height.
const ITEM_SELECTOR = "[data-chat-item]";
// findTopAnchor returns the first rendered item intersecting the viewport top.
// Spacers and other non-item children are ignored so the anchor is always a
// real element whose offset we can preserve.
function findTopAnchor(
scroller: HTMLElement,
content: HTMLElement,
): Anchor | null {
const scrollerTop = scroller.getBoundingClientRect().top;
for (const child of content.children) {
for (const child of content.querySelectorAll(ITEM_SELECTOR)) {
if (!(child instanceof HTMLElement)) {
continue;
}
const rect = child.getBoundingClientRect();
if (rect.bottom > scrollerTop) {
return { el: child, offset: rect.top - scrollerTop };
return {
id: child.getAttribute("data-chat-item-id"),
el: child,
offset: rect.top - scrollerTop,
};
}
}
return null;
}
function resolveAnchorElement(
content: HTMLElement,
anchor: Anchor,
): HTMLElement | null {
if (anchor.el.isConnected) {
return anchor.el;
}
if (anchor.id == null) {
return null;
}
const found = content.querySelector(
`[data-chat-item-id="${CSS.escape(anchor.id)}"]`,
);
return found instanceof HTMLElement ? found : null;
}
type ScrollAnchor = {
scrollerRef: RefObject<HTMLDivElement | null>;
contentRef: RefObject<HTMLDivElement | null>;
atBottom: boolean;
scrollToBottom: () => void;
maintainPin: () => void;
// captureAnchor records the item currently at the viewport top. Callers run
// it after every committed render so the anchor always reflects the latest
// settled layout.
captureAnchor: () => void;
// restoreAnchor re-pins to the bottom when sticky, otherwise moves the
// captured anchor element back to its recorded viewport offset. It is the
// single owner of scrollTop correction and is idempotent.
restoreAnchor: () => void;
};
export function useScrollAnchor(): ScrollAnchor {
@@ -55,14 +86,42 @@ export function useScrollAnchor(): ScrollAnchor {
setAtBottom(true);
}, []);
// maintainPin keeps the viewport pinned to the bottom while the user has not
// scrolled away. Callers run this in a layout effect on content changes so
// new messages pin synchronously, independent of ResizeObserver timing.
const maintainPin = useCallback(() => {
const captureAnchor = useCallback(() => {
const scroller = scrollerRef.current;
if (scroller && stickRef.current) {
scroller.scrollTop = scroller.scrollHeight;
const content = contentRef.current;
if (!scroller || !content) {
return;
}
anchorRef.current = stickRef.current
? null
: findTopAnchor(scroller, content);
}, []);
const restoreAnchor = useCallback(() => {
const scroller = scrollerRef.current;
const content = contentRef.current;
if (!scroller || !content) {
return;
}
if (stickRef.current) {
scroller.scrollTop = scroller.scrollHeight;
return;
}
const anchor = anchorRef.current;
if (!anchor) {
return;
}
const el = resolveAnchorElement(content, anchor);
if (!el) {
return;
}
const offset =
el.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
scroller.scrollTop = correctedScrollTop(
scroller.scrollTop,
anchor.offset,
offset,
);
}, []);
useEffect(() => {
@@ -76,10 +135,8 @@ export function useScrollAnchor(): ScrollAnchor {
const onScroll = () => {
const scrollTop = scroller.scrollTop;
// A real user scroll always moves scrollTop. Layout-induced scroll
// events keep the same scrollTop, so processing them would recapture
// the anchor against an already-mutated layout and make the resize
// correction a no-op. Our own correction does move scrollTop, so it
// still re-establishes a fresh anchor afterward.
// events keep the same scrollTop; ignoring them avoids flipping the
// stick state when our own correction nudges the position.
if (scrollTop === lastScrollTopRef.current) {
return;
}
@@ -90,7 +147,6 @@ export function useScrollAnchor(): ScrollAnchor {
scroller.clientHeight,
);
stickRef.current = bottom;
anchorRef.current = bottom ? null : findTopAnchor(scroller, content);
if (frame) {
return;
}
@@ -100,26 +156,12 @@ export function useScrollAnchor(): ScrollAnchor {
});
};
// ResizeObserver notifications run between layout and paint, so correcting
// scrollTop here keeps the viewport stable without a visible jump. Safari
// has no native scroll anchoring, which is why we must do this ourselves.
// The ResizeObserver is a fallback trigger for async intrinsic growth
// (streaming markdown, image load, late highlight) that happens without a
// React render. Windowing, measurement, and prepend are driven explicitly
// by the container's layout effect instead.
const resize = new ResizeObserver(() => {
if (stickRef.current) {
scroller.scrollTop = scroller.scrollHeight;
return;
}
const anchor = anchorRef.current;
if (!anchor) {
return;
}
const offset =
anchor.el.getBoundingClientRect().top -
scroller.getBoundingClientRect().top;
scroller.scrollTop = correctedScrollTop(
scroller.scrollTop,
anchor.offset,
offset,
);
restoreAnchor();
});
scroller.addEventListener("scroll", onScroll, { passive: true });
@@ -131,7 +173,14 @@ export function useScrollAnchor(): ScrollAnchor {
cancelAnimationFrame(frame);
}
};
}, []);
}, [restoreAnchor]);
return { scrollerRef, contentRef, atBottom, scrollToBottom, maintainPin };
return {
scrollerRef,
contentRef,
atBottom,
scrollToBottom,
captureAnchor,
restoreAnchor,
};
}
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { computeWindow, cumulativeOffsets } from "./windowMath";
describe("cumulativeOffsets", () => {
it("returns N+1 offsets with the total last", () => {
expect(cumulativeOffsets([100, 200, 50])).toEqual([0, 100, 300, 350]);
});
it("returns [0] for an empty list", () => {
expect(cumulativeOffsets([])).toEqual([0]);
});
});
describe("computeWindow", () => {
const overscan = 1000;
it("renders the whole list when it fits the window", () => {
const offsets = cumulativeOffsets([100, 200, 50]);
expect(
computeWindow({ offsets, scrollTop: 0, viewportHeight: 1000, overscan }),
).toEqual({ start: 0, end: 2, topPad: 0, bottomPad: 0 });
});
it("windows the middle of a long list with overscan", () => {
// 100 items of 100px each, total 10000.
const offsets = cumulativeOffsets(Array.from({ length: 100 }, () => 100));
const win = computeWindow({
offsets,
scrollTop: 5000,
viewportHeight: 500,
overscan,
});
// windowTop = 4000, windowBottom = 6500.
expect(win.start).toBe(40);
expect(win.end).toBe(64);
expect(win.topPad).toBe(4000);
expect(win.bottomPad).toBe(10000 - 6500);
});
it("clamps the top: scrollTop 0 gives start 0 and no top pad", () => {
const offsets = cumulativeOffsets(Array.from({ length: 100 }, () => 100));
const win = computeWindow({
offsets,
scrollTop: 0,
viewportHeight: 500,
overscan,
});
expect(win.start).toBe(0);
expect(win.topPad).toBe(0);
});
it("clamps the bottom: at max scroll, end is last and no bottom pad", () => {
const offsets = cumulativeOffsets(Array.from({ length: 100 }, () => 100));
const win = computeWindow({
offsets,
scrollTop: 10000 - 500,
viewportHeight: 500,
overscan,
});
expect(win.end).toBe(99);
expect(win.bottomPad).toBe(0);
});
it("returns an empty window for an empty list", () => {
expect(
computeWindow({
offsets: [0],
scrollTop: 0,
viewportHeight: 500,
overscan,
}),
).toEqual({ start: 0, end: -1, topPad: 0, bottomPad: 0 });
});
});
@@ -0,0 +1,66 @@
// Pure layout math for the windowing renderer. Kept free of DOM and React so it
// can be unit tested and reused by the container without re-rendering.
// cumulativeOffsets returns N+1 entries: the running top offset of each item,
// with the final entry holding the total height.
export function cumulativeOffsets(heights: readonly number[]): number[] {
const offsets = new Array<number>(heights.length + 1);
offsets[0] = 0;
for (let i = 0; i < heights.length; i++) {
offsets[i + 1] = offsets[i] + heights[i];
}
return offsets;
}
type WindowInput = {
offsets: readonly number[];
scrollTop: number;
viewportHeight: number;
overscan: number;
};
type Window = {
start: number;
end: number;
topPad: number;
bottomPad: number;
};
// computeWindow returns the inclusive [start, end] item range to render plus the
// spacer heights that reserve the unrendered ranges. The range covers the
// viewport expanded by `overscan` on each side. An empty list yields an empty
// range (end = -1) and zero pads.
export function computeWindow({
offsets,
scrollTop,
viewportHeight,
overscan,
}: WindowInput): Window {
const count = offsets.length - 1;
const total = offsets[count];
if (count <= 0) {
return { start: 0, end: -1, topPad: 0, bottomPad: 0 };
}
const windowTop = scrollTop - overscan;
const windowBottom = scrollTop + viewportHeight + overscan;
// First item whose bottom edge is past the window top.
let start = 0;
while (start < count - 1 && offsets[start + 1] <= windowTop) {
start++;
}
// Last item whose top edge is before the window bottom.
let end = start;
while (end < count - 1 && offsets[end + 1] < windowBottom) {
end++;
}
return {
start,
end,
topPad: offsets[start],
bottomPad: total - offsets[end + 1],
};
}