mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
refactor(site/src/pages/AgentsPage): normalize transcript scrolling to top-to-bottom flow (#23668)
Converts the Agents page transcript from reverse-scroll (\`flex-col-reverse\`) to normal top-to-bottom document flow with explicit auto-scroll pinning. The previous \`flex-col-reverse\` approach (from #23451) required browser-specific workarounds for Chrome vs Firefox \`scrollTop\` sign conventions, and compensation logic that could fight user scroll intent during streaming. This replaces it with standard scroll math (\`scrollHeight - scrollTop - clientHeight\`) while keeping the proven \`ScrollAnchoredContainer\` observer machinery. Changes: - \`AgentDetailView.tsx\`: layout flip to \`flex-col\`, standard bottom detection, explicit prepend restoration via \`pendingPrependRef\` snapshot, sentinel moved inside content wrapper, initial mount bottom pin, \`scrollToBottomRef\` imperative API for send/edit, user-interrupt guard with \`isNearBottom\` check - \`AgentDetailView.stories.tsx\`: all scroll stories updated for normal-flow semantics, new \`ScrollAnchorPreservedOnOlderHistoryLoad\` story with deterministic \`IntersectionObserver\` mock - \`AgentsSkeletons.tsx\` + \`AgentDetailLoadingView\`: removed \`flex-col-reverse\` so loading state matches the real transcript layout - \`AgentDetail.tsx\`: replaced \`scrollTop = 0\` on send/edit with imperative \`scrollToBottomRef\` call Supersedes #23576 (which was reverted in #23638). Same behavioral goals but keeps scroll logic local to \`ScrollAnchoredContainer\` rather than extracting a separate hook.
This commit is contained in:
@@ -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<ChatMessageInputRef | null>(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}
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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<void>((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<void>((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 },
|
||||
);
|
||||
|
||||
@@ -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<HTMLDivElement | null>;
|
||||
scrollToBottomRef?: RefObject<(() => void) | null>;
|
||||
|
||||
// Pagination for loading older messages.
|
||||
hasMoreMessages: boolean;
|
||||
@@ -172,6 +180,7 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
|
||||
handleUnarchiveAgentAction,
|
||||
handleArchiveAndDeleteWorkspaceAction,
|
||||
scrollContainerRef,
|
||||
scrollToBottomRef,
|
||||
hasMoreMessages,
|
||||
isFetchingMoreMessages,
|
||||
onFetchMoreMessages,
|
||||
@@ -187,6 +196,9 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
|
||||
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<AgentDetailViewProps> = ({
|
||||
{titleElement}
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden",
|
||||
"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden",
|
||||
visualExpanded && "hidden",
|
||||
shouldShowSidebar && "max-md:hidden",
|
||||
)}
|
||||
@@ -274,6 +286,7 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
|
||||
</div>
|
||||
<ScrollAnchoredContainer
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
scrollToBottomRef={effectiveScrollToBottomRef}
|
||||
isFetchingMoreMessages={isFetchingMoreMessages}
|
||||
hasMoreMessages={hasMoreMessages}
|
||||
onFetchMoreMessages={onFetchMoreMessages}
|
||||
@@ -427,7 +440,7 @@ export const AgentDetailLoadingView: FC<AgentDetailLoadingViewProps> = ({
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
|
||||
/>
|
||||
<div 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 className="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable] [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]">
|
||||
<div className="px-4">
|
||||
<div className="mx-auto w-full max-w-3xl py-6">
|
||||
<ChatConversationSkeleton />
|
||||
@@ -507,10 +520,10 @@ export const AgentDetailNotFoundView: FC<AgentDetailNotFoundViewProps> = ({
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<AgentDetailNotFoundViewProps> = ({
|
||||
* - 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<HTMLDivElement | null>;
|
||||
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<HTMLDivElement | null>;
|
||||
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<HTMLDivElement>(null);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const isFetchingRef = useRef(isFetchingMoreMessages);
|
||||
const hasFetchedRef = useRef(false);
|
||||
const onFetchRef = useRef(onFetchMoreMessages);
|
||||
const autoScrollRef = useRef(true);
|
||||
const contentRef = useRef<HTMLDivElement>(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<{
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
data-testid="scroll-container"
|
||||
className="flex min-h-0 flex-1 flex-col-reverse overflow-y-auto [overflow-anchor:none] [scrollbar-gutter:stable] [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]"
|
||||
className="flex min-h-0 flex-1 flex-col overflow-y-auto [overflow-anchor:none] [scrollbar-gutter:stable] [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]"
|
||||
>
|
||||
<div ref={contentRef}>{children}</div>
|
||||
{hasMoreMessages && <div ref={sentinelRef} className="h-px shrink-0" />}
|
||||
<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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FC } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { Skeleton } from "#/components/Skeleton/Skeleton";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
/** localStorage keys shared with the agents panel components. */
|
||||
const RIGHT_PANEL_OPEN_KEY = "agents.right-panel-open";
|
||||
@@ -163,7 +163,7 @@ export const AgentDetailSkeleton: FC = () => {
|
||||
<Skeleton className="h-7 w-7 rounded" />
|
||||
<Skeleton className="h-7 w-7 rounded" />
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col-reverse overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="px-4">
|
||||
<div className="mx-auto w-full max-w-3xl py-6">
|
||||
<ChatConversationSkeleton />
|
||||
|
||||
Reference in New Issue
Block a user