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:
Michael Suchacz
2026-03-27 01:31:17 +01:00
committed by GitHub
parent 4fab372bdc
commit f35f2a28e6
5 changed files with 299 additions and 103 deletions
+12 -14
View File
@@ -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}
+6 -5
View File
@@ -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 />