mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
refactor(site): replace custom scroll implementation with react-infinite-scroll-component (#24687)
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+15
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user