diff --git a/site/src/pages/AgentsPage/AgentDetail.tsx b/site/src/pages/AgentsPage/AgentDetail.tsx index 2d64c73cfd..03990e60eb 100644 --- a/site/src/pages/AgentsPage/AgentDetail.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.tsx @@ -1,4 +1,9 @@ import { useProxy } from "contexts/ProxyContext"; +import { + getTerminalHref, + getVSCodeHref, + openAppInNewWindow, +} from "modules/apps/apps"; import { type FC, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useInfiniteQuery, @@ -9,6 +14,9 @@ import { import { useOutletContext, useParams } from "react-router"; import { toast } from "sonner"; import type { UrlTransform } from "streamdown"; +import { isMobileViewport } from "utils/mobile"; +import { pageTitle } from "utils/page"; +import { rewriteLocalhostURL } from "utils/portForward"; import { API, watchWorkspace } from "#/api/api"; import { isApiError } from "#/api/errors"; import { @@ -29,14 +37,6 @@ import { deploymentSSHConfig } from "#/api/queries/deployment"; import { workspaceById, workspaceByIdKey } from "#/api/queries/workspaces"; import type * as TypesGen from "#/api/typesGenerated"; import type { ChatMessagePart } from "#/api/typesGenerated"; -import { - getTerminalHref, - getVSCodeHref, - openAppInNewWindow, -} from "#/modules/apps/apps"; -import { isMobileViewport } from "#/utils/mobile"; -import { pageTitle } from "#/utils/page"; -import { rewriteLocalhostURL } from "#/utils/portForward"; import type { AgentsOutletContext } from "./AgentsPage"; import type { ChatMessageInputRef } from "./components/AgentChatInput"; import { @@ -302,6 +302,7 @@ const AgentDetail: FC = () => { const [pendingEditMessageId, setPendingEditMessageId] = useState< number | null >(null); + const scrollToBottomRef = useRef<(() => void) | null>(null); const chatInputRef = useRef(null); const inputValueRef = useRef( agentId @@ -667,9 +668,7 @@ const AgentDetail: FC = () => { clearChatErrorReason(agentId); clearStreamError(); setPendingEditMessageId(editedMessageID); - if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTop = 0; - } + scrollToBottomRef.current?.(); try { await editMutation.mutateAsync({ messageId: editedMessageID, @@ -695,9 +694,7 @@ const AgentDetail: FC = () => { }; clearChatErrorReason(agentId); clearStreamError(); - if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTop = 0; - } + scrollToBottomRef.current?.(); // Don't clear stream state before the POST completes. // For queued sends the WebSocket status events handle @@ -963,6 +960,7 @@ const AgentDetail: FC = () => { } urlTransform={urlTransform} scrollContainerRef={scrollContainerRef} + scrollToBottomRef={scrollToBottomRef} hasMoreMessages={chatMessagesQuery.hasNextPage ?? false} isFetchingMoreMessages={chatMessagesQuery.isFetchingNextPage} onFetchMoreMessages={chatMessagesQuery.fetchNextPage} diff --git a/site/src/pages/AgentsPage/AgentEmbedPage.tsx b/site/src/pages/AgentsPage/AgentEmbedPage.tsx index f04864242d..f0d259fc3d 100644 --- a/site/src/pages/AgentsPage/AgentEmbedPage.tsx +++ b/site/src/pages/AgentsPage/AgentEmbedPage.tsx @@ -1,13 +1,13 @@ import { useAuthContext } from "contexts/auth/AuthProvider"; import { ProxyProvider } from "contexts/ProxyContext"; +import { DashboardProvider } from "modules/dashboard/DashboardProvider"; +import { permissionChecks } from "modules/permissions"; import { type FC, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useMutation, useQueryClient } from "react-query"; import { Outlet, useBlocker, useParams, useSearchParams } from "react-router"; import { getErrorMessage } from "#/api/errors"; import { Button } from "#/components/Button/Button"; import { Loader } from "#/components/Loader/Loader"; -import { DashboardProvider } from "#/modules/dashboard/DashboardProvider"; -import { permissionChecks } from "#/modules/permissions"; import type { AgentsOutletContext } from "./AgentsPage"; import { bootstrapChatEmbedSession, @@ -208,9 +208,10 @@ const AgentEmbedPage: FC = () => { return; } if (event.data?.type === "coder:scroll-to-bottom") { - // flex-col-reverse: scrollTop 0 is the visual bottom. + // Normal flow: scroll to the bottom of the transcript. if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTop = 0; + scrollContainerRef.current.scrollTop = + scrollContainerRef.current.scrollHeight; } } }; @@ -229,9 +230,9 @@ const AgentEmbedPage: FC = () => { clearChatErrorReason, requestArchiveAgent, requestUnarchiveAgent, - requestArchiveAndDeleteWorkspace, requestPinAgent: () => {}, requestUnpinAgent: () => {}, + requestArchiveAndDeleteWorkspace, isSidebarCollapsed, onToggleSidebarCollapsed, onExpandSidebar: () => {}, diff --git a/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx b/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx index ac923cb26b..6894dd9510 100644 --- a/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx @@ -522,11 +522,7 @@ const waitForScrollOverflow = async (scrollContainer: HTMLElement) => { }; const scrollAwayFromBottom = (scrollContainer: HTMLElement) => { - const maxScroll = scrollContainer.scrollHeight - scrollContainer.clientHeight; - scrollContainer.scrollTop = -maxScroll; - if (Math.abs(scrollContainer.scrollTop) < 100) { - scrollContainer.scrollTop = maxScroll; - } + scrollContainer.scrollTop = 0; scrollContainer.dispatchEvent(new Event("scroll")); }; @@ -569,10 +565,24 @@ export const ScrollToBottomButton: Story = { // Wait for content to render and create overflow. await waitForScrollOverflow(scrollContainer); - // Scroll up. In flex-col-reverse containers, Chrome uses - // negative scrollTop values when scrolled away from the - // bottom. Try negative first, fall back to positive for - // other engines. + // 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 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). @@ -611,6 +621,21 @@ export const ScrollPositionPreservedOnNewContent: Story = { 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); @@ -625,8 +650,11 @@ export const ScrollPositionPreservedOnNewContent: Story = { ); // Record position while clearly away from the bottom. - const scrollTopBefore = scrollContainer.scrollTop; - expect(Math.abs(scrollTopBefore)).toBeGreaterThan(50); + const distFromBottom = + scrollContainer.scrollHeight - + scrollContainer.scrollTop - + scrollContainer.clientHeight; + expect(distFromBottom).toBeGreaterThan(50); const existing = getStoreMessages(preservedScrollStore); preservedScrollStore.replaceMessages( @@ -648,7 +676,11 @@ export const ScrollPositionPreservedOnNewContent: Story = { // We should remain significantly away from the bottom. await waitFor( () => { - expect(Math.abs(scrollContainer.scrollTop)).toBeGreaterThan(50); + const dist = + scrollContainer.scrollHeight - + scrollContainer.scrollTop - + scrollContainer.clientHeight; + expect(dist).toBeGreaterThan(50); }, { timeout: 2000 }, ); @@ -671,8 +703,17 @@ export const ScrollPinnedToBottomOnNewContent: Story = { await waitForScrollOverflow(scrollContainer); - // Verify the starting position is pinned to the bottom. - expect(Math.abs(scrollContainer.scrollTop)).toBeLessThan(5); + // 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(); @@ -694,7 +735,11 @@ export const ScrollPinnedToBottomOnNewContent: Story = { // Wait for the double-RAF pin to complete. await waitFor( () => { - expect(Math.abs(scrollContainer.scrollTop)).toBeLessThan(5); + const dist = + scrollContainer.scrollHeight - + scrollContainer.scrollTop - + scrollContainer.clientHeight; + expect(dist).toBeLessThan(5); }, { timeout: 2000 }, ); diff --git a/site/src/pages/AgentsPage/components/AgentDetailView.tsx b/site/src/pages/AgentsPage/components/AgentDetailView.tsx index 2db8dec466..8b095b738a 100644 --- a/site/src/pages/AgentsPage/components/AgentDetailView.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetailView.tsx @@ -1,13 +1,20 @@ import { ArchiveIcon, ArrowDownIcon } from "lucide-react"; -import { type FC, type RefObject, useEffect, useRef, useState } from "react"; +import { + type FC, + type RefObject, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; import type { UrlTransform } from "streamdown"; +import { cn } from "utils/cn"; +import { pageTitle } from "utils/page"; import type * as TypesGen from "#/api/typesGenerated"; import type { ChatDiffStatus, ChatMessagePart } from "#/api/typesGenerated"; import type { ModelSelectorOption } from "#/components/ai-elements"; import { DesktopPanelContext } from "#/components/ai-elements/tool/DesktopPanelContext"; import { Button } from "#/components/Button/Button"; -import { cn } from "#/utils/cn"; -import { pageTitle } from "#/utils/page"; import type { ChatDetailError } from "../utils/usageLimitMessage"; import { AgentChatInput, type ChatMessageInputRef } from "./AgentChatInput"; import type { useChatStore } from "./AgentDetail/ChatContext"; @@ -113,6 +120,7 @@ interface AgentDetailViewProps { // Scroll container ref. scrollContainerRef: RefObject; + scrollToBottomRef?: RefObject<(() => void) | null>; // Pagination for loading older messages. hasMoreMessages: boolean; @@ -172,6 +180,7 @@ export const AgentDetailView: FC = ({ handleUnarchiveAgentAction, handleArchiveAndDeleteWorkspaceAction, scrollContainerRef, + scrollToBottomRef, hasMoreMessages, isFetchingMoreMessages, onFetchMoreMessages, @@ -187,6 +196,9 @@ export const AgentDetailView: FC = ({ null, ); const visualExpanded = dragVisualExpanded ?? isRightPanelExpanded; + const internalScrollToBottomRef = useRef<(() => void) | null>(null); + const effectiveScrollToBottomRef = + scrollToBottomRef ?? internalScrollToBottomRef; // State for programmatically switching the sidebar tab (e.g. when // the user clicks the inline desktop preview card). @@ -223,7 +235,7 @@ export const AgentDetailView: FC = ({ {titleElement}
= ({
= ({ isSidebarCollapsed={isSidebarCollapsed} onToggleSidebarCollapsed={onToggleSidebarCollapsed} /> -
+
@@ -507,10 +520,10 @@ export const AgentDetailNotFoundView: FC = ({ }; /** - * Scroll container that uses flex-col-reverse for bottom-anchored chat - * layout. In this layout scrollTop = 0 means the user is at the - * bottom (most recent content); scrolling up moves scrollTop away from - * 0 (negative in Chrome, positive in Firefox). + * Scroll container that keeps the transcript in normal top-to-bottom + * document flow while preserving a bottom-anchored chat experience. + * The user is at the bottom when the remaining scroll distance to the + * end of the container is within SCROLL_THRESHOLD. * * Handles: * - Loading older message pages via an IntersectionObserver sentinel. @@ -519,26 +532,61 @@ export const AgentDetailNotFoundView: FC = ({ * - A floating "Scroll to bottom" button when the user is scrolled * away from the bottom. * - * CSS scroll anchoring is unreliable in flex-col-reverse containers, - * so all position restoration is done manually. + * CSS overflow anchoring is disabled on the container, so all position + * restoration is done manually. */ const SCROLL_THRESHOLD = 100; -// In flex-col-reverse, scrollTop is 0 at the bottom. Its sign -// when scrolled up varies by engine (negative in Chrome, positive -// in Firefox). The user is "near bottom" when close to 0. function isNearBottom(container: HTMLElement): boolean { - return Math.abs(container.scrollTop) < SCROLL_THRESHOLD; + return ( + container.scrollHeight - container.scrollTop - container.clientHeight < + SCROLL_THRESHOLD + ); +} + +function scrollTranscriptToBottom({ + behavior, + scrollContainerRef, + autoScrollRef, + isRestoringScrollRef, + setShowScrollToBottom, +}: { + behavior: "smooth" | "instant"; + scrollContainerRef: RefObject; + autoScrollRef: { current: boolean }; + isRestoringScrollRef: { current: boolean }; + setShowScrollToBottom: (next: boolean) => void; +}): void { + const container = scrollContainerRef.current; + if (!container) { + return; + } + + autoScrollRef.current = true; + isRestoringScrollRef.current = true; + const top = Math.max(container.scrollHeight - container.clientHeight, 0); + if (behavior === "smooth") { + container.scrollTo({ top, behavior: "smooth" }); + } else { + container.scrollTop = top; + // Instant scrollTop assignment may not fire a scroll event when the + // container is already at the target position, so clear the restoring + // guard immediately to avoid blocking subsequent scroll handling. + isRestoringScrollRef.current = false; + } + setShowScrollToBottom(false); } const ScrollAnchoredContainer: FC<{ scrollContainerRef: RefObject; + scrollToBottomRef: RefObject<(() => void) | null>; isFetchingMoreMessages: boolean; hasMoreMessages: boolean; onFetchMoreMessages: () => void; children: React.ReactNode; }> = ({ scrollContainerRef, + scrollToBottomRef, isFetchingMoreMessages, hasMoreMessages, onFetchMoreMessages, @@ -547,21 +595,46 @@ const ScrollAnchoredContainer: FC<{ const sentinelRef = useRef(null); const observerRef = useRef(null); const isFetchingRef = useRef(isFetchingMoreMessages); + const hasFetchedRef = useRef(false); const onFetchRef = useRef(onFetchMoreMessages); const autoScrollRef = useRef(true); const contentRef = useRef(null); + const pendingPrependRef = useRef<{ + contentHeight: number; + scrollHeight: number; + contentWidth: number; + } | null>(null); // Guard flag: true while a programmatic scroll adjustment is in-flight. // The scroll handler skips autoScrollRef updates and re-render triggers // when this is set, preventing user-visible jitter. Cleared when the // scroll reaches its destination or the user actively interrupts. const isRestoringScrollRef = useRef(false); - useEffect(() => { + const cancelPendingPinsRef = useRef<(() => void) | null>(null); + useLayoutEffect(() => { isFetchingRef.current = isFetchingMoreMessages; + if (isFetchingMoreMessages) { + hasFetchedRef.current = true; + } onFetchRef.current = onFetchMoreMessages; }, [isFetchingMoreMessages, onFetchMoreMessages]); const [showScrollToBottom, setShowScrollToBottom] = useState(false); - // Sentinel observer — triggers loading older messages. + useEffect(() => { + scrollToBottomRef.current = () => { + scrollTranscriptToBottom({ + behavior: "instant", + scrollContainerRef, + autoScrollRef, + isRestoringScrollRef, + setShowScrollToBottom, + }); + }; + return () => { + scrollToBottomRef.current = null; + }; + }, [scrollContainerRef, scrollToBottomRef]); + + // Sentinel observer, triggers loading older messages. // All changing values are read from refs so the observer // is created once and never torn down / recreated, which // would cause spurious intersection callbacks. @@ -573,6 +646,18 @@ const ScrollAnchoredContainer: FC<{ const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting && !isFetchingRef.current) { + const container = scrollContainerRef.current; + const content = contentRef.current; + if (container && content) { + const contentRect = content.getBoundingClientRect(); + // Capture the current viewport snapshot before the fetch so + // prepended content can be restored after layout updates. + pendingPrependRef.current = { + contentHeight: contentRect.height, + scrollHeight: container.scrollHeight, + contentWidth: contentRect.width, + }; + } onFetchRef.current(); } }, @@ -583,8 +668,21 @@ const ScrollAnchoredContainer: FC<{ }, ); observerRef.current = observer; - observer.observe(sentinel); + // Defer sentinel observation until after the initial bottom + // pin settles. In normal flex-col flow, scrollTop starts at 0, + // which places the sentinel in the viewport and would trigger + // an eager history fetch before the transcript pins to bottom. + 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; }; @@ -597,12 +695,40 @@ const ScrollAnchoredContainer: FC<{ // again on its own. useEffect(() => { if (isFetchingMoreMessages) return; + // Skip re-observation on initial mount. The sentinel setup + // effect defers observation via double-RAF to avoid an eager + // fetch before the initial bottom pin settles. This effect + // would bypass that defer since isFetchingMoreMessages starts + // as false. + if (!hasFetchedRef.current) return; + const pendingPrepend = pendingPrependRef.current; + const cleanupId = requestAnimationFrame(() => { + const container = scrollContainerRef.current; + if ( + !pendingPrepend || + pendingPrependRef.current !== pendingPrepend || + !container + ) { + return; + } + // If the fetch did not change the container scroll height, the + // ResizeObserver never runs to clear the pending prepend snapshot. + // Clear it after layout settles so later resizes do not apply stale + // scroll compensation. + if (Math.abs(container.scrollHeight - pendingPrepend.scrollHeight) < 1) { + pendingPrependRef.current = null; + } + }); const sentinel = sentinelRef.current; const observer = observerRef.current; - if (!sentinel || !observer) return; - observer.unobserve(sentinel); - observer.observe(sentinel); - }, [isFetchingMoreMessages]); + if (sentinel && observer) { + observer.unobserve(sentinel); + observer.observe(sentinel); + } + return () => { + cancelAnimationFrame(cleanupId); + }; + }, [isFetchingMoreMessages, scrollContainerRef]); useEffect(() => { const container = scrollContainerRef.current; @@ -643,7 +769,11 @@ const ScrollAnchoredContainer: FC<{ if (restoreGuardRafId !== null) { cancelAnimationFrame(restoreGuardRafId); } - container.scrollTop = 0; + container.scrollTop = Math.max( + container.scrollHeight - container.clientHeight, + 0, + ); + setShowScrollToBottom(false); restoreGuardRafId = requestAnimationFrame(() => { isRestoringScrollRef.current = false; restoreGuardRafId = null; @@ -652,26 +782,7 @@ const ScrollAnchoredContainer: FC<{ }); }; - const compensateScroll = (delta: number) => { - if (restoreGuardRafId !== null) { - cancelAnimationFrame(restoreGuardRafId); - } - isRestoringScrollRef.current = true; - // In flex-col-reverse, "away from bottom" can be either - // negative (Chrome) or positive (Firefox). Detect which - // convention applies and compensate accordingly. - if (container.scrollTop < 0) { - // Negative convention: subtract to move away from 0 (bottom). - container.scrollTop -= delta; - } else { - // Positive convention: add to move away from 0 (bottom). - container.scrollTop += delta; - } - restoreGuardRafId = requestAnimationFrame(() => { - isRestoringScrollRef.current = false; - restoreGuardRafId = null; - }); - }; + cancelPendingPinsRef.current = cancelPendingPins; const observer = new ResizeObserver((entries) => { const entry = entries[0]; @@ -683,14 +794,37 @@ const ScrollAnchoredContainer: FC<{ const widthChanged = Math.abs(nextWidth - prevContentWidth) > 1; prevContentHeight = nextHeight; prevContentWidth = nextWidth; + + const pending = pendingPrependRef.current; + if (pending !== null && isFetchingRef.current) { + pending.contentHeight = nextHeight; + pending.scrollHeight = container.scrollHeight; + pending.contentWidth = nextWidth; + return; + } if (Math.abs(delta) < 1) { return; } - // Skip compensation during pagination. Older messages are - // prepended in flex-col-reverse which grows content into the - // overflow direction; the browser preserves scrollTop for us. - if (isFetchingRef.current) { + // Restore the viewport after older messages are prepended. + if (pending !== null && !isFetchingRef.current) { + pendingPrependRef.current = null; + // Width changes indicate reflow rather than a true prepend. + if (!widthChanged) { + const scrollHeightDelta = + container.scrollHeight - pending.scrollHeight; + if (scrollHeightDelta > 0) { + if (restoreGuardRafId !== null) { + cancelAnimationFrame(restoreGuardRafId); + } + isRestoringScrollRef.current = true; + container.scrollTop = container.scrollTop + scrollHeightDelta; + restoreGuardRafId = requestAnimationFrame(() => { + isRestoringScrollRef.current = false; + restoreGuardRafId = null; + }); + } + } return; } @@ -707,11 +841,20 @@ const ScrollAnchoredContainer: FC<{ return; } - compensateScroll(delta); + // In normal flow, appends grow below the viewport, so users reading + // history do not need scroll compensation. }); observer.observe(content); + // In normal flex-col flow, scrollTop starts at 0 (top). + // Pin to bottom on initial mount so existing chats open + // at the most recent messages. + if (autoScrollRef.current) { + scheduleBottomPin(); + } + return () => { + cancelPendingPinsRef.current = null; observer.disconnect(); cancelPendingPins(); if (restoreGuardRafId !== null) { @@ -741,7 +884,10 @@ const ScrollAnchoredContainer: FC<{ cancelAnimationFrame(restoreGuardRafId); } isRestoringScrollRef.current = true; - container.scrollTop = 0; + container.scrollTop = Math.max( + container.scrollHeight - container.clientHeight, + 0, + ); restoreGuardRafId = requestAnimationFrame(() => { isRestoringScrollRef.current = false; restoreGuardRafId = null; @@ -759,9 +905,6 @@ const ScrollAnchoredContainer: FC<{ }, [scrollContainerRef]); // Track scroll position to show/hide the scroll-to-bottom button. - // In a flex-col-reverse container, scrollTop = 0 means the user - // is at the bottom (most recent content). Scrolling up moves - // scrollTop away from 0, with the sign varying by engine. useEffect(() => { const container = scrollContainerRef.current; if (!container) return; @@ -778,6 +921,7 @@ const ScrollAnchoredContainer: FC<{ if (isNearBottom(container)) { isRestoringScrollRef.current = false; autoScrollRef.current = true; + setShowScrollToBottom(false); } return; } @@ -798,8 +942,15 @@ const ScrollAnchoredContainer: FC<{ }; const handleUserInterrupt = () => { - if (isRestoringScrollRef.current) { - isRestoringScrollRef.current = false; + // Always clear the restoration guard so the next scroll event is + // processed normally. + isRestoringScrollRef.current = false; + // Only disable auto-scroll when the user is away from the bottom. + // Trackpad noise or accidental wheel events at the bottom should + // not break streaming follow-mode. + if (!isNearBottom(container)) { + autoScrollRef.current = false; + cancelPendingPinsRef.current?.(); } }; @@ -821,16 +972,13 @@ const ScrollAnchoredContainer: FC<{ }, [scrollContainerRef]); const handleScrollToBottom = () => { - const container = scrollContainerRef.current; - if (!container) return; - autoScrollRef.current = true; - isRestoringScrollRef.current = true; - container.scrollTo({ top: 0, behavior: "smooth" }); - // Hide immediately so the button doesn't linger while the - // smooth scroll animates. If the user interrupts the scroll - // before it reaches the bottom, the scroll handler will - // re-show the button. - setShowScrollToBottom(false); + scrollTranscriptToBottom({ + behavior: "smooth", + scrollContainerRef, + autoScrollRef, + isRestoringScrollRef, + setShowScrollToBottom, + }); }; return ( @@ -838,10 +986,14 @@ const ScrollAnchoredContainer: FC<{
-
{children}
- {hasMoreMessages &&
} +
+ {hasMoreMessages && ( +
+ )} + {children} +
-
+