refactor(site): replace custom scroll implementation with react-infinite-scroll-component (#24687)

This commit is contained in:
Danielle Maywood
2026-04-23 22:12:39 +01:00
committed by GitHub
parent a02339c66a
commit 4505278a9f
6 changed files with 377 additions and 1170 deletions
+1
View File
@@ -97,6 +97,7 @@
"react-confetti": "6.4.0",
"react-day-picker": "9.14.0",
"react-dom": "19.2.2",
"react-infinite-scroll-component": "7.1.0",
"react-markdown": "9.1.0",
"react-query": "npm:@tanstack/react-query@5.77.0",
"react-resizable-panels": "3.0.6",
+15
View File
@@ -193,6 +193,9 @@ importers:
react-dom:
specifier: 19.2.2
version: 19.2.2(react@19.2.2)
react-infinite-scroll-component:
specifier: 7.1.0
version: 7.1.0(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
react-markdown:
specifier: 9.1.0
version: 9.1.0(@types/react@19.2.7)(react@19.2.2)
@@ -5234,6 +5237,13 @@ packages:
react-fast-compare@2.0.4:
resolution: {integrity: sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==, tarball: https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz}
react-infinite-scroll-component@7.1.0:
resolution: {integrity: sha512-EPUMyOnpmJDqI1aoUi9uR/TSUfJCUN77ZkpzYSshGwrC2NTaH6p+rxaP/2DZJWygOZmZcAieZk4VciF8q9H/tw==, tarball: https://registry.npmjs.org/react-infinite-scroll-component/-/react-infinite-scroll-component-7.1.0.tgz}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=17.0.0'
react-dom: '>=17.0.0'
react-inspector@6.0.2:
resolution: {integrity: sha512-x+b7LxhmHXjHoU/VrFAzw5iutsILRoYyDq97EDYdFpPLcvqtEzk4ZSZSQjnFPbr5T57tLXnHcqFYoN1pI6u8uQ==, tarball: https://registry.npmjs.org/react-inspector/-/react-inspector-6.0.2.tgz}
peerDependencies:
@@ -11678,6 +11688,11 @@ snapshots:
react-fast-compare@2.0.4: {}
react-infinite-scroll-component@7.1.0(react-dom@19.2.2(react@19.2.2))(react@19.2.2):
dependencies:
react: 19.2.2
react-dom: 19.2.2(react@19.2.2)
react-inspector@6.0.2(react@19.2.2):
dependencies:
react: 19.2.2
@@ -1462,6 +1462,7 @@ const AgentChatPage: FC = () => {
hasMoreMessages={chatMessagesQuery.hasNextPage ?? false}
isFetchingMoreMessages={chatMessagesQuery.isFetchingNextPage}
onFetchMoreMessages={chatMessagesQuery.fetchNextPage}
messageCount={storeMessageCount}
desktopChatId={desktopEnabled ? agentId : undefined}
mcpServers={mcpServers}
selectedMCPServerIds={effectiveMCPServerIds}
@@ -1,5 +1,5 @@
import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
import type { ComponentProps, FC } from "react";
import { type ComponentProps, type FC, useRef } from "react";
import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { API } from "#/api/api";
@@ -21,7 +21,10 @@ import {
AgentChatPageNotFoundView,
AgentChatPageView,
} from "./AgentChatPageView";
import { createChatStore } from "./components/ChatConversation/chatStore";
import {
createChatStore,
useChatSelector,
} from "./components/ChatConversation/chatStore";
import type { ModelSelectorOption } from "./components/ChatElements";
import type { ChatDetailError } from "./utils/usageLimitMessage";
@@ -115,6 +118,15 @@ type StoryProps = Omit<
};
const StoryAgentChatPageView: FC<StoryProps> = ({ editing, ...overrides }) => {
const defaultStoreRef = useRef(createChatStore());
const defaultScrollContainerRef = useRef<HTMLDivElement | null>(null);
const defaultScrollToBottomRef = useRef<(() => void) | null>(null);
const store = overrides.store ?? defaultStoreRef.current;
const messageCount = useChatSelector(
store,
(state) => state.messagesByID.size,
);
const props = {
agentId: AGENT_ID,
organizationId: "test-org-id",
@@ -122,7 +134,6 @@ const StoryAgentChatPageView: FC<StoryProps> = ({ editing, ...overrides }) => {
persistedError: undefined as ChatDetailError | undefined,
parentChat: undefined as TypesGen.Chat | undefined,
isArchived: false,
store: createChatStore(),
effectiveSelectedModel: defaultModelConfigID,
setSelectedModel: fn(),
modelOptions: defaultModelOptions,
@@ -151,7 +162,9 @@ const StoryAgentChatPageView: FC<StoryProps> = ({ editing, ...overrides }) => {
handleUnarchiveAgentAction: fn(),
handleArchiveAndDeleteWorkspaceAction: fn(),
handleRegenerateTitle: fn(),
scrollContainerRef: { current: null },
scrollContainerRef:
overrides.scrollContainerRef ?? defaultScrollContainerRef,
scrollToBottomRef: overrides.scrollToBottomRef ?? defaultScrollToBottomRef,
hasMoreMessages: false,
isFetchingMoreMessages: false,
onFetchMoreMessages: fn(),
@@ -162,6 +175,8 @@ const StoryAgentChatPageView: FC<StoryProps> = ({ editing, ...overrides }) => {
onMCPSelectionChange: fn(),
onMCPAuthComplete: fn(),
...overrides,
store,
messageCount: overrides.messageCount ?? messageCount,
editing: buildEditing(editing),
};
return <AgentChatPageView {...props} />;
@@ -603,7 +618,7 @@ export const NotFoundSidebarCollapsed: Story = {
};
// ---------------------------------------------------------------------------
// Scroll-to-bottom button stories
// Infinite scroll stories
// ---------------------------------------------------------------------------
/** Generate a long conversation so the scroll container overflows. */
@@ -644,19 +659,58 @@ const waitForScrollOverflow = async (scrollContainer: HTMLElement) => {
});
};
const scrollAwayFromBottom = (scrollContainer: HTMLElement) => {
// Dispatch a wheel event first so the scroll handler treats
// this as user-initiated scrolling and disables follow mode.
// A bare scrollTop assignment fires a scroll event but the
// handler only re-pins (never disables autoScroll) unless
// a user-interaction event (wheel/touch/pointer) is active.
scrollContainer.dispatchEvent(
new WheelEvent("wheel", { bubbles: true, deltaY: -100 }),
);
const scrollToHistoryTop = (scrollContainer: HTMLElement) => {
// In the library's documented column-reverse layout, older history is
// reached by driving the scroll offset toward the negative extreme.
scrollContainer.scrollTop = -scrollContainer.scrollHeight;
scrollContainer.dispatchEvent(new Event("scroll"));
};
const scrollToLatestMessages = (scrollContainer: HTMLElement) => {
scrollContainer.scrollTop = 0;
scrollContainer.dispatchEvent(new Event("scroll"));
};
const waitForFetchCount = async (
fetchSpy: ReturnType<typeof fn>,
count: number,
) => {
await waitFor(() => {
expect(fetchSpy).toHaveBeenCalledTimes(count);
});
};
const waitForVisibleText = async (
canvas: ReturnType<typeof within>,
text: string,
) => {
await waitFor(() => {
// The chat timeline renders hidden measurement copies for some message
// layouts, so pick any visible match instead of assuming the first node is
// the one a user sees.
const matches = canvas.queryAllByText(text);
const hasVisibleMatch = matches.some((element: Element) => {
const style = window.getComputedStyle(element);
return (
style.display !== "none" &&
style.visibility !== "hidden" &&
element.getClientRects().length > 0
);
});
expect(hasVisibleMatch).toBe(true);
});
};
const waitForIntersectionObserverTick = async () => {
await new Promise<void>((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
resolve();
});
});
});
};
/** Helper that extracts the current messages array from a store. */
const getStoreMessages = (
store: ReturnType<typeof createChatStore>,
@@ -672,540 +726,229 @@ const getStoreMessages = (
return messages;
};
/** Scroll-to-bottom button appears after scrolling up in a long
* conversation, and clicking it returns to the bottom. */
export const ScrollToBottomButton: Story = {
const prependOlderMessages = (
store: ReturnType<typeof createChatStore>,
count: number,
) => {
const existing = getStoreMessages(store);
const oldestMessage = existing[0];
const oldestID = oldestMessage?.id ?? 1;
const olderMessages = Array.from({ length: count }, (_, index) => {
const id = oldestID - count + index;
const role: TypesGen.ChatMessageRole = id % 2 === 0 ? "assistant" : "user";
const text =
role === "user"
? `Older question ${Math.abs(id)}.`
: `Older answer ${Math.abs(id)}.`;
return buildMessage(id, role, text);
});
store.replaceMessages([...olderMessages, ...existing]);
};
const resetScrollStoryStore = (
store: ReturnType<typeof createChatStore>,
// Default to a transcript long enough to overflow the 600px decorator so the
// inverse-scroll stories exercise the fetch threshold immediately.
count = 80,
) => {
store.replaceMessages(buildLongConversation(count));
store.setChatStatus("completed");
};
const inverseScrollStore = buildStoreWithMessages(buildLongConversation(80));
const inverseScrollFetchSpy = fn(() => {
prependOlderMessages(inverseScrollStore, 10);
});
/**
* Scrolling upward in the library's inverse mode loads older messages into the
* top of the transcript.
*/
export const InverseScrollLoadsOlderMessages: Story = {
parameters: { chromatic: { disableSnapshot: true } },
decorators: scrollStoryDecorators,
render: () => (
<StoryAgentChatPageView
store={buildStoreWithMessages(buildLongConversation(40))}
store={inverseScrollStore}
hasMoreMessages
onFetchMoreMessages={inverseScrollFetchSpy}
/>
),
play: async ({ canvasElement }) => {
resetScrollStoryStore(inverseScrollStore);
inverseScrollFetchSpy.mockClear();
const canvas = within(canvasElement);
const scrollContainer = canvas.getByTestId("scroll-container");
await waitForScrollOverflow(scrollContainer);
expect(inverseScrollFetchSpy).not.toHaveBeenCalled();
scrollToHistoryTop(scrollContainer);
await waitForFetchCount(inverseScrollFetchSpy, 1);
await waitForVisibleText(canvas, "Older question 9.");
},
};
const multiPageScrollStore = buildStoreWithMessages(buildLongConversation(80));
const multiPageFetchSpy = fn(() => {
prependOlderMessages(multiPageScrollStore, 10);
});
/**
* The library resets its one-shot load guard when dataLength changes, so a
* second upward reveal can load another page.
*/
export const InverseScrollCanLoadMultiplePages: Story = {
parameters: { chromatic: { disableSnapshot: true } },
decorators: scrollStoryDecorators,
render: () => (
<StoryAgentChatPageView
store={multiPageScrollStore}
hasMoreMessages
onFetchMoreMessages={multiPageFetchSpy}
/>
),
play: async ({ canvasElement }) => {
resetScrollStoryStore(multiPageScrollStore);
multiPageFetchSpy.mockClear();
const canvas = within(canvasElement);
// The button should be hidden initially — it has aria-hidden="true"
// when not shown, so queryByRole correctly returns null.
expect(
canvas.queryByRole("button", { name: "Scroll to bottom" }),
).toBeNull();
// Find the scroll container via data-testid.
const scrollContainer = canvas.getByTestId("scroll-container");
// Wait for content to render and create overflow.
await waitForScrollOverflow(scrollContainer);
// Wait for the initial bottom pin to settle before scrolling away.
await waitFor(
() => {
const dist =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight;
expect(dist).toBeLessThan(5);
},
{ timeout: 2000 },
);
await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
);
scrollToHistoryTop(scrollContainer);
await waitForFetchCount(multiPageFetchSpy, 1);
await waitForVisibleText(canvas, "Older question 9.");
// Scroll to the top (away from bottom). In normal top-to-bottom
// flow, scrollTop = 0 is at the top and the user is farthest
// from the bottom of the conversation.
scrollAwayFromBottom(scrollContainer);
// Button should become visible (enters the accessibility tree).
const button = await waitFor(() => {
const btn = canvas.getByRole("button", { name: "Scroll to bottom" });
expect(btn).toBeVisible();
return btn;
scrollToLatestMessages(scrollContainer);
await waitFor(() => {
expect(scrollContainer.scrollTop).toBe(0);
});
await waitForIntersectionObserverTick();
scrollToHistoryTop(scrollContainer);
// Click the button to scroll back to the bottom.
await userEvent.click(button);
await waitForFetchCount(multiPageFetchSpy, 2);
await waitForVisibleText(canvas, "Older answer 10.");
},
};
const scrollToBottomButtonStoryStore = buildStoreWithMessages(
buildLongConversation(80),
);
/**
* The replacement container should keep the floating affordance that returns a
* user from older history to the newest messages.
*/
export const ScrollToBottomButtonWorksWithInverseScroll: Story = {
parameters: { chromatic: { disableSnapshot: true } },
decorators: scrollStoryDecorators,
render: () => (
<StoryAgentChatPageView store={scrollToBottomButtonStoryStore} />
),
play: async ({ canvasElement }) => {
resetScrollStoryStore(scrollToBottomButtonStoryStore);
const canvas = within(canvasElement);
const scrollContainer = canvas.getByTestId("scroll-container");
await waitForScrollOverflow(scrollContainer);
expect(
canvas.queryByRole("button", { name: /scroll to bottom/i }),
).toBeNull();
scrollToHistoryTop(scrollContainer);
// Button should be hidden again. The click handler immediately
// hides it, so this doesn't depend on smooth scroll completing.
await waitFor(() => {
expect(
canvas.queryByRole("button", { name: "Scroll to bottom" }),
canvas.getByRole("button", { name: /scroll to bottom/i }),
).toBeVisible();
});
await userEvent.click(
canvas.getByRole("button", { name: /scroll to bottom/i }),
);
await waitFor(() => {
expect(scrollContainer.scrollTop).toBe(0);
expect(
canvas.queryByRole("button", { name: /scroll to bottom/i }),
).toBeNull();
});
},
};
// Each scroll story that mutates the store in its play function
// creates the store at module scope so the play closure can reach
// it. Stories in a file execute sequentially, so there is no
// cross-contamination.
const preservedScrollStore = buildStoreWithMessages(buildLongConversation(30));
const scrollToBottomStoryStore = buildStoreWithMessages(
buildLongConversation(80),
);
// Story objects live at module scope, so use a ref-shaped object instead of a
// hook to capture the imperative callback across the render and play phases.
const scrollToBottomStoryRef: { current: (() => void) | null } = {
current: null,
};
/** When scrolled away from bottom, new content preserves scroll position. */
export const ScrollPositionPreservedOnNewContent: Story = {
/**
* Page-level send and edit flows still rely on an imperative scroll-to-bottom
* hook, so the replacement container must keep that contract working.
*/
export const ScrollToBottomRefStillWorks: Story = {
parameters: { chromatic: { disableSnapshot: true } },
decorators: scrollStoryDecorators,
render: () => <StoryAgentChatPageView store={preservedScrollStore} />,
render: () => (
<StoryAgentChatPageView
store={scrollToBottomStoryStore}
scrollToBottomRef={scrollToBottomStoryRef}
/>
),
play: async ({ canvasElement }) => {
resetScrollStoryStore(scrollToBottomStoryStore);
const canvas = within(canvasElement);
const scrollContainer = canvas.getByTestId("scroll-container");
await waitForScrollOverflow(scrollContainer);
// Wait for the initial bottom pin to settle before scrolling away.
await waitFor(
() => {
const dist =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight;
expect(dist).toBeLessThan(5);
},
{ timeout: 2000 },
);
await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
);
// Scroll away from bottom.
scrollAwayFromBottom(scrollContainer);
// Wait for the button to confirm we are away from the bottom.
await waitFor(
() => {
expect(
canvas.getByRole("button", { name: "Scroll to bottom" }),
).toBeVisible();
},
{ timeout: 2000 },
);
// Record position while clearly away from the bottom.
const distFromBottom =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight;
expect(distFromBottom).toBeGreaterThan(50);
const existing = getStoreMessages(preservedScrollStore);
preservedScrollStore.replaceMessages(
existing.concat([
buildMessage(
31,
"user",
"Follow-up question about the implementation.",
),
buildMessage(
32,
"assistant",
"Here is a detailed response about the implementation details you asked about.",
),
]),
);
// Wait for ResizeObserver + RAF compensation to settle.
// We should remain significantly away from the bottom.
await waitFor(
() => {
const dist =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight;
expect(dist).toBeGreaterThan(50);
},
{ timeout: 2000 },
);
expect(
canvas.getByRole("button", { name: "Scroll to bottom" }),
).toBeVisible();
},
};
const pinnedScrollStore = buildStoreWithMessages(buildLongConversation(30));
/** When at bottom, new content keeps the user pinned to bottom. */
export const ScrollPinnedToBottomOnNewContent: Story = {
parameters: { chromatic: { disableSnapshot: true } },
decorators: scrollStoryDecorators,
render: () => <StoryAgentChatPageView store={pinnedScrollStore} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scrollContainer = canvas.getByTestId("scroll-container");
await waitForScrollOverflow(scrollContainer);
// Wait for the initial bottom pin (double-RAF) to settle.
await waitFor(
() => {
const dist =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight;
expect(dist).toBeLessThan(5);
},
{ timeout: 2000 },
);
expect(
canvas.queryByRole("button", { name: "Scroll to bottom" }),
).toBeNull();
const existing = getStoreMessages(pinnedScrollStore);
pinnedScrollStore.replaceMessages(
existing.concat([
buildMessage(31, "user", "Another question."),
buildMessage(32, "assistant", "Here is the answer with full details."),
buildMessage(33, "user", "Thanks, one more thing."),
buildMessage(
34,
"assistant",
"Sure, here is the additional information you requested.",
),
]),
);
// Wait for the double-RAF pin to complete.
await waitFor(
() => {
const dist =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight;
expect(dist).toBeLessThan(5);
},
{ timeout: 2000 },
);
expect(
canvas.queryByRole("button", { name: "Scroll to bottom" }),
).toBeNull();
},
};
const dispatchTouchEvent = (
scrollContainer: HTMLElement,
type: "touchstart" | "touchend",
changedTouchesLength: number,
) => {
const event = new Event(type, { bubbles: true });
Object.defineProperty(event, "changedTouches", {
configurable: true,
value: Array.from({ length: changedTouchesLength }, (_, index) => ({
identifier: index,
})),
});
scrollContainer.dispatchEvent(event);
};
const touchGuardScrollStore = buildStoreWithMessages(buildLongConversation(30));
/** During an active touch gesture, the container ResizeObserver must not
* snap scroll to bottom. This prevents the mobile URL bar resize jump. */
export const ScrollNotJumpedDuringTouch: Story = {
parameters: { chromatic: { disableSnapshot: true } },
decorators: scrollStoryDecorators,
render: () => <StoryAgentChatPageView store={touchGuardScrollStore} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scrollContainer = canvas.getByTestId("scroll-container");
await waitForScrollOverflow(scrollContainer);
// Wait for the initial bottom pin to settle.
await waitFor(
() => {
const dist =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight;
expect(dist).toBeLessThan(5);
},
{ timeout: 2000 },
);
await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
);
// Simulate a multi-touch gesture starting with two fingers down.
dispatchTouchEvent(scrollContainer, "touchstart", 2);
// Scroll partway up, within the 100px threshold but not at the
// absolute bottom. This simulates the user dragging up slightly
// during a touch.
const offsetFromBottom = 50;
const targetScrollTop =
scrollContainer.scrollHeight -
scrollContainer.clientHeight -
offsetFromBottom;
scrollContainer.scrollTop = targetScrollTop;
scrollContainer.dispatchEvent(new Event("scroll"));
const originalHeight = scrollContainer.clientHeight;
const shrunkHeight = originalHeight - 10;
// Record the scroll position before the first resize.
const scrollTopBeforeFirstResize = scrollContainer.scrollTop;
// Simulate a container resize that models the mobile URL bar
// appearing. Shrink the container height slightly to trigger
// the ResizeObserver.
scrollContainer.style.height = `${shrunkHeight}px`;
// Give the ResizeObserver a chance to fire.
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
// During an active touch, the resize guard should prevent the
// container observer from snapping to the absolute bottom.
expect(scrollContainer.scrollTop).toBeLessThanOrEqual(
scrollTopBeforeFirstResize + 1,
);
// Lift one finger, leaving a second touch active. The guard should
// still block resize snaps until the final finger is lifted.
dispatchTouchEvent(scrollContainer, "touchend", 1);
const scrollTopBeforeSecondResize = scrollContainer.scrollTop;
scrollContainer.style.height = `${originalHeight}px`;
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
expect(scrollContainer.scrollTop).toBeLessThanOrEqual(
scrollTopBeforeSecondResize + 1,
);
// End the remaining touch.
dispatchTouchEvent(scrollContainer, "touchend", 1);
// After touch ends, normal scroll tracking should resume.
// Scroll to the very bottom and verify the button disappears.
scrollContainer.scrollTop =
scrollContainer.scrollHeight - scrollContainer.clientHeight;
scrollContainer.dispatchEvent(new Event("scroll"));
scrollToHistoryTop(scrollContainer);
await waitFor(() => {
expect(
canvas.queryByRole("button", { name: "Scroll to bottom" }),
).toBeNull();
expect(scrollContainer.scrollTop).toBeLessThan(0);
expect(typeof scrollToBottomStoryRef.current).toBe("function");
});
const scrollToBottom = scrollToBottomStoryRef.current;
if (!scrollToBottom) {
throw new Error("Expected scrollToBottomRef to be available.");
}
scrollToBottom();
await waitFor(() => {
expect(scrollContainer.scrollTop).toBe(0);
});
},
};
const wheelGuardScrollStore = buildStoreWithMessages(buildLongConversation(30));
const messageOrderStore = buildStoreWithMessages([
buildMessage(1, "user", "Oldest message"),
buildMessage(2, "assistant", "Older response"),
buildMessage(3, "user", "Newer question"),
buildMessage(4, "assistant", "Newest reply"),
]);
/** During active wheel/trackpad scrolling, the container ResizeObserver
* must not snap scroll to bottom. This prevents desktop scroll jump. */
export const ScrollNotJumpedDuringWheel: Story = {
/**
* The reversed container layout must not invert the transcript's visible order.
*/
export const MessageOrderIsStillCorrect: Story = {
parameters: { chromatic: { disableSnapshot: true } },
decorators: scrollStoryDecorators,
render: () => <StoryAgentChatPageView store={wheelGuardScrollStore} />,
render: () => <StoryAgentChatPageView store={messageOrderStore} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scrollContainer = canvas.getByTestId("scroll-container");
await waitForScrollOverflow(scrollContainer);
// Wait for the initial bottom pin to settle.
await waitFor(
() => {
const dist =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight;
expect(dist).toBeLessThan(5);
},
{ timeout: 2000 },
);
await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
);
// Simulate a wheel event (trackpad/mouse scroll).
scrollContainer.dispatchEvent(
new WheelEvent("wheel", { bubbles: true, deltaY: -50 }),
);
// Scroll partway up, within the 100px threshold but not at
// the absolute bottom. This simulates the user scrolling up
// slightly with a trackpad.
const offsetFromBottom = 25;
const targetScrollTop =
scrollContainer.scrollHeight -
scrollContainer.clientHeight -
offsetFromBottom;
scrollContainer.scrollTop = targetScrollTop;
scrollContainer.dispatchEvent(new Event("scroll"));
const scrollHeightBeforeAppend = scrollContainer.scrollHeight;
// Simulate new assistant content arriving while the wheel guard is
// active. Keep the append small enough to remain within the
// near-bottom threshold so auto-follow should resume.
const existing = getStoreMessages(wheelGuardScrollStore);
wheelGuardScrollStore.replaceMessages(
existing.concat([buildMessage(31, "assistant", "Short update.")]),
);
const oldest = canvas.getByText("Oldest message");
const newer = canvas.getByText("Newest reply");
await waitFor(() => {
expect(scrollContainer.scrollHeight).toBeGreaterThan(
scrollHeightBeforeAppend,
expect(oldest.getBoundingClientRect().top).toBeLessThan(
newer.getBoundingClientRect().top,
);
});
// After wheeling up, the user has expressed intent to
// disengage auto-follow. Content growth should NOT yank
// them back to the bottom — they keep their position.
await waitFor(() => {
const dist =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight;
// The user should NOT have been yanked to the absolute
// bottom. They may still be within the "near bottom"
// visual threshold, but scrollTop must not have been
// forced to maxScrollTop.
expect(dist).toBeGreaterThan(5);
});
},
};
const wheelDeferredStore = buildStoreWithMessages(buildLongConversation(30));
/**
* Regression: when content grows during a wheel burst (so
* ResizeObserver pins are deferred), the transcript must recover
* auto-follow after the wheel debounce expires instead of getting
* stuck in a jumped-up position.
*/
export const ScrollRepinnedAfterWheelDeferredAppend: Story = {
parameters: { chromatic: { disableSnapshot: true } },
decorators: scrollStoryDecorators,
render: () => <StoryAgentChatPageView store={wheelDeferredStore} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scrollContainer = canvas.getByTestId("scroll-container");
await waitForScrollOverflow(scrollContainer);
// Wait for the initial bottom pin to settle.
await waitFor(
() => {
const dist =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight;
expect(dist).toBeLessThan(5);
},
{ timeout: 2000 },
);
await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
);
// Simulate a wheel event (no debounce guard in the new code).
scrollContainer.dispatchEvent(
new WheelEvent("wheel", { bubbles: true, deltaY: 3 }),
);
// Append content while a wheel event is active. The new
// implementation pins immediately via ResizeObserver rather
// than deferring through a wheel guard.
const existing = getStoreMessages(wheelDeferredStore);
wheelDeferredStore.replaceMessages(
existing.concat([
buildMessage(31, "assistant", "A ".repeat(200)),
buildMessage(32, "assistant", "B ".repeat(200)),
]),
);
// Fire a second wheel tick. The new code processes this
// as a downward wheel event and does not disengage
// follow mode.
scrollContainer.dispatchEvent(
new WheelEvent("wheel", { bubbles: true, deltaY: 3 }),
);
// The new code pins synchronously via ResizeObserver.
// Verify the scroll position settled at the bottom.
await waitFor(
() => {
const dist =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight;
expect(dist).toBeLessThan(5);
},
{ timeout: 2000 },
);
// Scroll-to-bottom button should not be visible.
expect(
canvas.queryByRole("button", { name: "Scroll to bottom" }),
).toBeNull();
},
};
const editSubmitScrollStore = buildStoreWithMessages(buildLongConversation(30));
/**
* Verifies that the scroll position settles at the bottom of the
* conversation after an optimistic edit truncation removes messages.
* The actual scroll-ordering regression (scrollToBottom must fire
* after editMessage resolves) is covered by the submitEditAndScroll
* unit tests in AgentChatPage.test.ts.
*/
export const ScrollStableAfterEditTruncation: Story = {
parameters: { chromatic: { disableSnapshot: true } },
decorators: scrollStoryDecorators,
render: () => <StoryAgentChatPageView store={editSubmitScrollStore} />,
play: async ({ canvasElement }) => {
// Reset the module-scoped store so interactive re-runs in
// Storybook start from the full 30-message conversation.
editSubmitScrollStore.replaceMessages(buildLongConversation(30));
editSubmitScrollStore.setChatStatus("completed");
const canvas = within(canvasElement);
const scrollContainer = canvas.getByTestId("scroll-container");
await waitForScrollOverflow(scrollContainer);
await waitFor(
() => {
const dist =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight;
expect(dist).toBeLessThan(5);
},
{ timeout: 2000 },
);
const existing = getStoreMessages(editSubmitScrollStore);
const editIndex = 10;
const truncated = existing.slice(0, editIndex);
truncated.push(
buildMessage(existing[editIndex].id, "user", "Edited question"),
);
editSubmitScrollStore.replaceMessages(truncated);
await waitFor(
() => {
const dist =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight;
expect(dist).toBeLessThan(5);
},
{ timeout: 2000 },
);
expect(
canvas.queryByRole("button", { name: "Scroll to bottom" }),
).toBeNull();
},
};
@@ -159,6 +159,7 @@ interface AgentChatPageViewProps {
hasMoreMessages: boolean;
isFetchingMoreMessages: boolean;
onFetchMoreMessages: () => void;
messageCount: number;
urlTransform?: UrlTransform;
@@ -229,6 +230,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
hasMoreMessages,
isFetchingMoreMessages,
onFetchMoreMessages,
messageCount,
urlTransform,
mcpServers,
selectedMCPServerIds,
@@ -454,6 +456,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
isFetchingMoreMessages={isFetchingMoreMessages}
hasMoreMessages={hasMoreMessages}
onFetchMoreMessages={onFetchMoreMessages}
messageCount={messageCount}
>
<div className="px-4">
<ChatPageTimeline
@@ -1,723 +1,167 @@
import { ArrowDownIcon } from "lucide-react";
import {
type FC,
type RefCallback,
type ReactNode,
type RefObject,
useEffect,
useEffectEvent,
useLayoutEffect,
useRef,
useState,
} from "react";
import InfiniteScroll from "react-infinite-scroll-component";
import { Button } from "#/components/Button/Button";
import { cn } from "#/utils/cn";
// ===========================================================================
// useStickToBottom — scroll-lock hook
// ===========================================================================
const SCROLL_THRESHOLD = "600px";
const SCROLL_TO_BOTTOM_BUTTON_OFFSET_PX = 70;
/** Pixel threshold for "near bottom" detection. */
const STICK_TO_BOTTOM_OFFSET_PX = 70;
const ScrollToBottomButton: FC<{
scrollContainerElement: HTMLDivElement | null;
messageCount: number;
onScrollToBottom: () => void;
}> = ({ scrollContainerElement, messageCount, onScrollToBottom }) => {
const [showScrollToBottomButton, setShowScrollToBottomButton] =
useState(false);
// ---------------------------------------------------------------------------
// Mutable state (not tied to React render cycle)
// ---------------------------------------------------------------------------
interface InternalState {
scrollElement: HTMLElement | null;
contentElement: HTMLElement | null;
programmaticScrollCount: number;
resizeDifference: number;
lastScrollTop: number;
lastClientHeight: number;
escapedFromLock: boolean;
internalIsAtBottom: boolean;
resizeObserver: ResizeObserver | null;
viewportObserver: ResizeObserver | null;
previousContentHeight: number | undefined;
mouseDown: boolean;
suppressNextResize: boolean;
activeTouchCount: number;
pendingPrepend: { scrollHeight: number } | null;
}
/** The maximum scrollable offset for the container. */
function maxScrollTop(s: InternalState): number {
if (!s.scrollElement) {
return 0;
}
return Math.max(
0,
s.scrollElement.scrollHeight - s.scrollElement.clientHeight,
);
}
/** Whether the scroll position is within the stick-to-bottom threshold. */
function isNearBottom(s: InternalState): boolean {
if (!s.scrollElement) {
return false;
}
const distance = maxScrollTop(s) - s.scrollElement.scrollTop;
return distance <= STICK_TO_BOTTOM_OFFSET_PX;
}
/** Assign scrollTop and bump the programmatic-scroll counter so
* the next scroll event is not misread as a user-initiated scroll.
* Only bumps the counter when scrollTop actually changes, so no-op
* writes don't orphan a counter increment without a matching event. */
function scrollTo(s: InternalState, value: number) {
if (!s.scrollElement) {
return;
}
const prev = s.scrollElement.scrollTop;
s.scrollElement.scrollTop = value;
if (s.scrollElement.scrollTop !== prev) {
s.programmaticScrollCount++;
}
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
interface StickToBottomInstance {
scrollRef: RefCallback<HTMLDivElement>;
contentRef: RefCallback<HTMLDivElement>;
/** Scroll to the bottom. Pass `"instant"` to jump; omit for smooth. */
scrollToBottom: (behavior?: ScrollBehavior) => void;
/** True when the view is locked to the bottom or physically near it. */
isAtBottom: boolean;
/** Tell the hook to skip the next content resize auto-pin. */
suppressNextResize: () => void;
/** Capture scrollHeight for prepend restoration. */
capturePrependSnapshot: () => void;
}
function useStickToBottom(): StickToBottomInstance {
const [isAtBottom, setIsAtBottom] = useState(true);
const [nearBottom, setNearBottom] = useState(false);
const stateRef = useRef<InternalState>({
scrollElement: null,
contentElement: null,
programmaticScrollCount: 0,
resizeDifference: 0,
lastScrollTop: 0,
lastClientHeight: 0,
escapedFromLock: false,
internalIsAtBottom: true,
resizeObserver: null,
viewportObserver: null,
previousContentHeight: undefined,
mouseDown: false,
suppressNextResize: false,
activeTouchCount: 0,
pendingPrepend: null,
});
// Sync helpers — keep mutable state and React state in lockstep.
const syncIsAtBottom = (v: boolean) => {
stateRef.current.internalIsAtBottom = v;
setIsAtBottom(v);
};
const syncEscapedFromLock = (v: boolean) => {
stateRef.current.escapedFromLock = v;
};
// -----------------------------------------------------------------------
// scrollToBottom
// -----------------------------------------------------------------------
const scrollToBottom = (behavior?: ScrollBehavior) => {
const s = stateRef.current;
if (!s.scrollElement) return;
syncIsAtBottom(true);
syncEscapedFromLock(false);
const top = maxScrollTop(s);
if (behavior === "instant") {
scrollTo(s, top);
} else {
s.scrollElement.scrollTo({
top,
behavior: behavior ?? "smooth",
});
// Don't bump programmaticScrollCount for smooth scroll.
// Each animation frame naturally reads as a downward
// scroll (currentScrollTop > lastScrollTop), which
// correctly clears escapedFromLock.
}
};
const suppressNextResize = () => {
stateRef.current.suppressNextResize = true;
};
const capturePrependSnapshot = () => {
const s = stateRef.current;
if (s.scrollElement) {
s.pendingPrepend = {
scrollHeight: s.scrollElement.scrollHeight,
};
}
};
// -----------------------------------------------------------------------
// Event handlers
// -----------------------------------------------------------------------
const handleScroll = useEffectEvent((e: Event) => {
const s = stateRef.current;
const { scrollElement } = s;
if (e.target !== scrollElement || !scrollElement) return;
const currentScrollTop = scrollElement.scrollTop;
// Detect viewport-size changes (e.g. Safari PWA toolbar
// settling, virtual keyboard, safe-area inset shifts).
// The browser may clamp scrollTop before the
// ResizeObserver fires, so this scroll event would look
// like an upward user scroll without this guard.
const currentClientHeight = scrollElement.clientHeight;
const viewportChanged = currentClientHeight !== s.lastClientHeight;
s.lastClientHeight = currentClientHeight;
// If this event was caused by a programmatic scrollTo,
// consume the counter and skip escape processing.
if (s.programmaticScrollCount > 0) {
s.programmaticScrollCount--;
s.lastScrollTop = currentScrollTop;
setNearBottom(isNearBottom(s));
useEffect(() => {
if (!scrollContainerElement) {
setShowScrollToBottomButton(false);
return;
}
const lastST = s.lastScrollTop;
s.lastScrollTop = currentScrollTop;
setNearBottom(isNearBottom(s));
// Synchronous escape logic — must run before any resize
// handler so they see up-to-date internalIsAtBottom.
// Skip when a content resize or viewport resize is in
// progress — the browser may fire scroll events during
// layout that aren’t user-initiated.
if (
s.resizeDifference === 0 &&
!viewportChanged &&
s.activeTouchCount === 0
) {
if (currentScrollTop < lastST) {
// If we believe we're at the bottom and the user
// hasn't escaped via wheel, touch, or scrollbar,
// this upward movement is browser-initiated (e.g.
// Safari scroll restoration, focus-driven scroll).
// Re-pin instead of escaping.
if (s.internalIsAtBottom && !s.escapedFromLock) {
scrollTo(s, maxScrollTop(s));
} else {
syncEscapedFromLock(true);
syncIsAtBottom(false);
}
} else if (currentScrollTop > lastST) {
syncEscapedFromLock(false);
let frameId: number | null = null;
const updateVisibility = () => {
setShowScrollToBottomButton(
Math.abs(scrollContainerElement.scrollTop) >
SCROLL_TO_BOTTOM_BUTTON_OFFSET_PX,
);
};
const handleScroll = () => {
if (frameId !== null) {
return;
}
if (!s.escapedFromLock && isNearBottom(s)) {
syncIsAtBottom(true);
}
}
// Text-selection escape deferred — getSelection() needs
// post-layout DOM state to reflect the current drag.
setTimeout(() => {
if (s.resizeDifference !== 0) return;
if (s.mouseDown && s.scrollElement) {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const ancestor = sel.getRangeAt(0).commonAncestorContainer;
const el =
ancestor instanceof HTMLElement ? ancestor : ancestor.parentElement;
if (
el &&
(s.scrollElement.contains(el) || el.contains(s.scrollElement))
) {
syncEscapedFromLock(true);
syncIsAtBottom(false);
}
}
}
}, 1);
});
const handleWheel = useEffectEvent((e: WheelEvent) => {
const s = stateRef.current;
// Walk up from target to find the nearest scrollable ancestor.
let el = e.target as HTMLElement | null;
while (el && el !== s.scrollElement) {
if (el.scrollHeight > el.clientHeight) {
const style = getComputedStyle(el);
if (
style.overflow === "scroll" ||
style.overflow === "auto" ||
style.overflowY === "scroll" ||
style.overflowY === "auto"
) {
break;
}
}
el = el.parentElement;
}
if (
el === s.scrollElement &&
e.deltaY < 0 &&
s.scrollElement &&
s.scrollElement.scrollHeight > s.scrollElement.clientHeight
) {
syncEscapedFromLock(true);
syncIsAtBottom(false);
// Cancel any in-progress smooth scroll so the animation
// doesn't override the user's escape intent.
s.scrollElement.scrollTo({
top: s.scrollElement.scrollTop,
behavior: "instant",
frameId = requestAnimationFrame(() => {
frameId = null;
updateVisibility();
});
}
});
};
const handleContentResize = useEffectEvent(() => {
const s = stateRef.current;
if (!s.contentElement || !s.scrollElement) return;
const currentHeight = s.contentElement.getBoundingClientRect().height;
const previousHeight = s.previousContentHeight;
const difference =
previousHeight !== undefined ? currentHeight - previousHeight : 0;
s.resizeDifference = difference;
// Clamp browser overscroll.
const target = maxScrollTop(s);
if (s.scrollElement.scrollTop > target) {
scrollTo(s, target);
}
setNearBottom(isNearBottom(s));
// Skip auto-pin while touch contacts are active to
// prevent mobile URL bar resizes from fighting the
// user's finger.
if (s.activeTouchCount > 0) {
// No auto-pin during active touch.
} else if (s.pendingPrepend) {
// Prepend restoration: older messages were just added
// to the DOM. Adjust scrollTop by the height delta so
// the user's visual position stays the same.
const delta =
s.scrollElement.scrollHeight - s.pendingPrepend.scrollHeight;
if (delta > 0) {
const prev = s.scrollElement.scrollTop;
s.scrollElement.scrollTop = prev + delta;
if (s.scrollElement.scrollTop !== prev) {
s.programmaticScrollCount++;
}
}
s.pendingPrepend = null;
} else if (s.suppressNextResize) {
s.suppressNextResize = false;
} else if (difference >= 0) {
if (previousHeight === undefined) {
// First observation — jump to bottom instantly.
if (s.internalIsAtBottom) {
scrollTo(s, target);
}
} else {
// Check whether we were near the OLD bottom before
// this resize. We can't rely on internalIsAtBottom
// alone because scroll events fire during browser
// layout (before this ResizeObserver callback), and
// the handler may have disengaged the lock when it
// saw the scroll position was far from the new,
// taller bottom.
const prevMaxScroll = Math.max(
0,
previousHeight - s.scrollElement.clientHeight,
);
const wasAtBottom =
s.internalIsAtBottom ||
(!s.escapedFromLock &&
s.scrollElement.scrollTop >=
prevMaxScroll - STICK_TO_BOTTOM_OFFSET_PX);
if (wasAtBottom) {
scrollTo(s, target);
syncIsAtBottom(true);
syncEscapedFromLock(false);
}
}
} else if (isNearBottom(s)) {
// Content shrank and we ended up near bottom — re-engage.
syncEscapedFromLock(false);
syncIsAtBottom(true);
}
s.previousContentHeight = currentHeight;
// Clear after rAF + setTimeout(1) so the scroll handler has
// a chance to see the resize flag before it resets.
const captured = s.resizeDifference;
requestAnimationFrame(() => {
setTimeout(() => {
if (s.resizeDifference === captured) {
s.resizeDifference = 0;
}
}, 1);
updateVisibility();
scrollContainerElement.addEventListener("scroll", handleScroll, {
passive: true,
});
});
// When the scroll container's viewport dimensions change (e.g.
// the top bar gains elements after async data loads), maxScrollTop
// shifts and we may no longer be at the bottom. Re-pin if locked
// or physically near the bottom. The near-bottom fallback handles
// the case where the browser clamped scrollTop before this
// observer fired, causing the synchronous escape logic in
// handleScroll to disengage the lock.
const handleViewportResize = useEffectEvent(() => {
const s = stateRef.current;
if (!s.scrollElement) return;
const maxST = maxScrollTop(s);
const near = isNearBottom(s);
if (s.activeTouchCount > 0 || (!s.internalIsAtBottom && !near)) {
return () => {
scrollContainerElement.removeEventListener("scroll", handleScroll);
if (frameId !== null) {
cancelAnimationFrame(frameId);
}
};
}, [scrollContainerElement]);
useEffect(() => {
if (!scrollContainerElement) {
return;
}
scrollTo(s, maxST);
syncIsAtBottom(true);
syncEscapedFromLock(false);
});
const handleTouchStart = useEffectEvent((e: TouchEvent) => {
stateRef.current.activeTouchCount += Math.max(e.changedTouches.length, 1);
syncEscapedFromLock(true);
syncIsAtBottom(false);
});
const handleTouchEnd = useEffectEvent((e: TouchEvent) => {
const s = stateRef.current;
s.activeTouchCount = Math.max(
0,
s.activeTouchCount - Math.max(e.changedTouches.length, 1),
setShowScrollToBottomButton(
messageCount > 0 &&
Math.abs(scrollContainerElement.scrollTop) >
SCROLL_TO_BOTTOM_BUTTON_OFFSET_PX,
);
});
}, [messageCount, scrollContainerElement]);
// -----------------------------------------------------------------------
// Ref callbacks
// -----------------------------------------------------------------------
const handlePointerDown = useEffectEvent((e: PointerEvent) => {
const s = stateRef.current;
// e.target === s.scrollElement is only true when clicking
// the scrollbar track/thumb, not content inside the container.
if (e.target === s.scrollElement) {
syncEscapedFromLock(true);
syncIsAtBottom(false);
}
});
// Ref callbacks must have stable identity — React cycles them
// on identity change, which leaks event listeners. Store the
// element in state and let a useEffect manage listeners.
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(
null,
);
useEffect(() => {
const s = stateRef.current;
s.scrollElement = scrollElement;
if (!scrollElement) return;
s.lastClientHeight = scrollElement.clientHeight;
scrollElement.addEventListener("scroll", handleScroll, { passive: true });
scrollElement.addEventListener("wheel", handleWheel, { passive: true });
scrollElement.addEventListener("touchstart", handleTouchStart, {
passive: true,
});
scrollElement.addEventListener("touchend", handleTouchEnd, {
passive: true,
});
scrollElement.addEventListener("touchcancel", handleTouchEnd, {
passive: true,
});
scrollElement.addEventListener("pointerdown", handlePointerDown);
const vo = new ResizeObserver(handleViewportResize);
vo.observe(scrollElement);
s.viewportObserver = vo;
return () => {
scrollElement.removeEventListener("touchstart", handleTouchStart);
scrollElement.removeEventListener("touchend", handleTouchEnd);
scrollElement.removeEventListener("touchcancel", handleTouchEnd);
scrollElement.removeEventListener("scroll", handleScroll);
scrollElement.removeEventListener("wheel", handleWheel);
scrollElement.removeEventListener("pointerdown", handlePointerDown);
if (s.viewportObserver) {
s.viewportObserver.disconnect();
s.viewportObserver = null;
}
};
}, [scrollElement]);
const [contentElement, setContentElement] = useState<HTMLDivElement | null>(
null,
);
useEffect(() => {
const s = stateRef.current;
s.contentElement = contentElement;
if (!contentElement) return;
const ro = new ResizeObserver(handleContentResize);
ro.observe(contentElement);
s.resizeObserver = ro;
return () => {
ro.disconnect();
s.resizeObserver = null;
};
}, [contentElement]);
// -----------------------------------------------------------------------
// Mouse tracking (instance-scoped)
// -----------------------------------------------------------------------
useEffect(() => {
const s = stateRef.current;
const onDown = () => {
s.mouseDown = true;
};
const onUp = () => {
s.mouseDown = false;
};
document.addEventListener("mousedown", onDown);
document.addEventListener("mouseup", onUp);
document.addEventListener("click", onUp);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("mouseup", onUp);
document.removeEventListener("click", onUp);
};
}, []);
// Reset touch counter on tab switch. The browser may not
// fire touchend/touchcancel when the user switches away
// mid-gesture, leaving the counter positive and blocking
// resize observer pins permanently.
useEffect(() => {
const s = stateRef.current;
const handleVisibilityChange = () => {
if (document.hidden) {
s.activeTouchCount = 0;
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);
// Post-render consistency check. If we believe we're pinned
// to the bottom but the physical scroll position disagrees,
// correct it before the browser paints. This catches any
// race between ResizeObserver callbacks, browser scroll
// clamping, and React re-renders (e.g. Safari PWA viewport
// settling after navigation).
// Intentionally no deps — runs every render as a safety net.
useLayoutEffect(() => {
const s = stateRef.current;
if (!s.scrollElement || !s.internalIsAtBottom) return;
const target = maxScrollTop(s);
// 1px tolerance for sub-pixel rounding.
if (target - s.scrollElement.scrollTop > 1) {
scrollTo(s, target);
}
});
return {
scrollRef: setScrollElement,
contentRef: setContentElement,
scrollToBottom,
isAtBottom: isAtBottom || nearBottom,
suppressNextResize,
capturePrependSnapshot,
const handleScrollToBottom = () => {
onScrollToBottom();
setShowScrollToBottomButton(false);
};
}
// ===========================================================================
// ChatScrollContainer — the scroll-anchored wrapper for the chat transcript
// ===========================================================================
return (
<div className="pointer-events-none absolute inset-x-0 bottom-2 z-10 flex justify-center overflow-y-auto py-2 [scrollbar-gutter:stable] [scrollbar-width:thin]">
<Button
variant="outline"
size="icon"
className={cn(
"rounded-full bg-surface-primary shadow-md transition-all duration-200",
showScrollToBottomButton
? "pointer-events-auto translate-y-0 opacity-100"
: "translate-y-2 opacity-0",
)}
onClick={handleScrollToBottom}
aria-label="Scroll to bottom"
aria-hidden={!showScrollToBottomButton || undefined}
tabIndex={showScrollToBottomButton ? undefined : -1}
>
<ArrowDownIcon />
</Button>
</div>
);
};
/**
* Scroll container that keeps the transcript pinned to the bottom using
* ResizeObserver-driven scroll tracking. Handles:
* - Stick-to-bottom with automatic re-engagement when content grows.
* - Loading older message pages via an IntersectionObserver sentinel.
* - Scroll position restoration when older messages are prepended.
* - A floating "Scroll to bottom" button when the user scrolls away.
*/
const ChatScrollContainer: FC<{
scrollContainerRef: RefObject<HTMLDivElement | null>;
scrollToBottomRef: RefObject<(() => void) | null>;
isFetchingMoreMessages: boolean;
hasMoreMessages: boolean;
onFetchMoreMessages: () => void;
children: React.ReactNode;
messageCount: number;
children: ReactNode;
}> = ({
scrollContainerRef,
scrollToBottomRef,
isFetchingMoreMessages,
hasMoreMessages,
onFetchMoreMessages,
messageCount,
children,
}) => {
const {
scrollRef,
contentRef,
scrollToBottom,
isAtBottom,
capturePrependSnapshot,
} = useStickToBottom();
const [scrollContainerElement, setScrollContainerElement] =
useState<HTMLDivElement | null>(null);
// Merge our callback ref with the external RefObject so both
// point at the same DOM node, and expose scrollToBottom to the
// parent via its imperative ref.
const mergedScrollRef = (el: HTMLDivElement | null) => {
scrollRef(el);
scrollContainerRef.current = el;
scrollToBottomRef.current = el ? () => scrollToBottom("instant") : null;
const scrollToBottom = () => {
// Read the live ref so remounts cannot leave callers targeting a detached
// scroll node.
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
return;
}
// In the library's reversed layout, the newest messages sit at the visual
// bottom, which maps to a zero scroll offset.
scrollContainer.scrollTop = 0;
};
// -------------------------------------------------------------------
// Pagination sentinel (IntersectionObserver)
// -------------------------------------------------------------------
const sentinelRef = useRef<HTMLDivElement>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const isFetchingRef = useRef(isFetchingMoreMessages);
const hasFetchedRef = useRef(false);
const wasFetchingRef = useRef(false);
useLayoutEffect(() => {
const wasFetching = wasFetchingRef.current;
isFetchingRef.current = isFetchingMoreMessages;
wasFetchingRef.current = isFetchingMoreMessages;
if (!wasFetching && isFetchingMoreMessages) {
hasFetchedRef.current = true;
capturePrependSnapshot();
}
// Restoration happens in handleContentResize (via
// pendingPrepend) when the DOM actually reflects the
// prepended content — not here, because the store
// update may lag behind isFetchingMoreMessages.
}, [isFetchingMoreMessages, capturePrependSnapshot]);
useEffect(() => {
const sentinel = sentinelRef.current;
const container = scrollContainerRef.current;
if (!sentinel || !container) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !isFetchingRef.current) {
onFetchMoreMessages();
}
},
{
root: container,
rootMargin: "600px 0px 0px 0px",
threshold: 0.01,
},
);
observerRef.current = observer;
// Defer observation via double-rAF so the initial bottom
// pin settles before the sentinel can trigger.
let deferInnerId: number | null = null;
const deferOuterId = requestAnimationFrame(() => {
deferInnerId = requestAnimationFrame(() => {
observer.observe(sentinel);
});
});
return () => {
cancelAnimationFrame(deferOuterId);
if (deferInnerId !== null) {
cancelAnimationFrame(deferInnerId);
}
observer.disconnect();
observerRef.current = null;
};
}, [scrollContainerRef, onFetchMoreMessages]);
// Re-observe the sentinel after a fetch completes so the
// IntersectionObserver fires again if it stayed visible.
useEffect(() => {
if (isFetchingMoreMessages) return;
if (!hasFetchedRef.current) return;
const sentinel = sentinelRef.current;
const observer = observerRef.current;
if (sentinel && observer) {
observer.unobserve(sentinel);
observer.observe(sentinel);
}
}, [isFetchingMoreMessages]);
// -------------------------------------------------------------------
// Render
// -------------------------------------------------------------------
const showButton = !isAtBottom;
const setScrollContainer = (element: HTMLDivElement | null) => {
scrollContainerRef.current = element;
setScrollContainerElement(element);
scrollToBottomRef.current = element ? scrollToBottom : null;
};
return (
<div className="relative flex min-h-0 flex-1 flex-col">
<div
ref={mergedScrollRef}
ref={setScrollContainer}
data-testid="scroll-container"
className="flex min-h-0 flex-1 flex-col overflow-y-auto [overflow-anchor:none] [overscroll-behavior:contain] [scrollbar-gutter:stable] [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]"
aria-busy={isFetchingMoreMessages || undefined}
className="flex min-h-0 flex-1 flex-col-reverse overflow-y-auto [scrollbar-gutter:stable] [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]"
>
<div ref={contentRef}>
{hasMoreMessages && (
<div ref={sentinelRef} className="h-px shrink-0" />
)}
{children}
</div>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-2 z-10 flex justify-center overflow-y-auto py-2 [scrollbar-gutter:stable] [scrollbar-width:thin]">
<Button
variant="outline"
size="icon"
className={cn(
"rounded-full bg-surface-primary shadow-md transition-all duration-200",
showButton
? "pointer-events-auto translate-y-0 opacity-100"
: "translate-y-2 opacity-0",
)}
onClick={() => scrollToBottom()}
aria-label="Scroll to bottom"
aria-hidden={!showButton || undefined}
tabIndex={showButton ? undefined : -1}
<div aria-hidden className="flex-1 basis-0" />
<InfiniteScroll
dataLength={messageCount}
next={onFetchMoreMessages}
hasMore={hasMoreMessages}
inverse
scrollableTarget={scrollContainerElement ?? undefined}
scrollThreshold={SCROLL_THRESHOLD}
hasChildren={messageCount > 0}
loader={isFetchingMoreMessages ? <div aria-hidden /> : null}
endMessage={null}
style={{ display: "flex", flexDirection: "column-reverse" }}
>
<ArrowDownIcon />
</Button>
{children}
</InfiniteScroll>
</div>
<ScrollToBottomButton
scrollContainerElement={scrollContainerElement}
messageCount={messageCount}
onScrollToBottom={scrollToBottom}
/>
</div>
);
};