From 4505278a9f91772300600a58f8ff2fd42bd250f2 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 23 Apr 2026 22:12:39 +0100 Subject: [PATCH] refactor(site): replace custom scroll implementation with react-infinite-scroll-component (#24687) --- site/package.json | 1 + site/pnpm-lock.yaml | 15 + site/src/pages/AgentsPage/AgentChatPage.tsx | 1 + .../AgentsPage/AgentChatPageView.stories.tsx | 747 ++++++----------- .../pages/AgentsPage/AgentChatPageView.tsx | 3 + .../components/ChatScrollContainer.tsx | 780 +++--------------- 6 files changed, 377 insertions(+), 1170 deletions(-) diff --git a/site/package.json b/site/package.json index acac10abdf..fa3df6454f 100644 --- a/site/package.json +++ b/site/package.json @@ -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", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 1473dca2fb..7a7fef3683 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -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 diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 98bbb6cdda..1a7100fd81 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -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} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index 592eb062e2..afd0e138df 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -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 = ({ editing, ...overrides }) => { + const defaultStoreRef = useRef(createChatStore()); + const defaultScrollContainerRef = useRef(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 = ({ 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 = ({ 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 = ({ editing, ...overrides }) => { onMCPSelectionChange: fn(), onMCPAuthComplete: fn(), ...overrides, + store, + messageCount: overrides.messageCount ?? messageCount, editing: buildEditing(editing), }; return ; @@ -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, + count: number, +) => { + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledTimes(count); + }); +}; + +const waitForVisibleText = async ( + canvas: ReturnType, + 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((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + resolve(); + }); + }); + }); +}; + /** Helper that extracts the current messages array from a store. */ const getStoreMessages = ( store: ReturnType, @@ -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, + 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, + // 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: () => ( ), 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: () => ( + + ), + 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((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: () => ( + + ), + 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: () => , + render: () => ( + + ), 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((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: () => , - 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: () => , - 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((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((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((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: () => , + render: () => , 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((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: () => , - 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((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: () => , - 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(); }, }; diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 6a333b5bb9..232617f0ee 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -159,6 +159,7 @@ interface AgentChatPageViewProps { hasMoreMessages: boolean; isFetchingMoreMessages: boolean; onFetchMoreMessages: () => void; + messageCount: number; urlTransform?: UrlTransform; @@ -229,6 +230,7 @@ export const AgentChatPageView: FC = ({ hasMoreMessages, isFetchingMoreMessages, onFetchMoreMessages, + messageCount, urlTransform, mcpServers, selectedMCPServerIds, @@ -454,6 +456,7 @@ export const AgentChatPageView: FC = ({ isFetchingMoreMessages={isFetchingMoreMessages} hasMoreMessages={hasMoreMessages} onFetchMoreMessages={onFetchMoreMessages} + messageCount={messageCount} >
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; - contentRef: RefCallback; - /** 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({ - 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( - 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( - 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 ( +
+ +
+ ); +}; -/** - * 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; 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(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(null); - const observerRef = useRef(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 (
-
- {hasMoreMessages && ( -
- )} - {children} -
-
-
- + {children} +
+
); };