fix: revert "refactor(site/src/pages/AgentsPage): normalize transcript scrolling" (#23638)

Reverts coder/coder#23576
This commit is contained in:
Kyle Carberry
2026-03-25 20:24:42 +00:00
committed by GitHub
parent 4025b582cd
commit 09bcd0b260
4 changed files with 275 additions and 294 deletions
+2 -4
View File
@@ -668,8 +668,7 @@ const AgentDetail: FC = () => {
clearStreamError();
setPendingEditMessageId(editedMessageID);
if (scrollContainerRef.current) {
const el = scrollContainerRef.current;
el.scrollTop = el.scrollHeight - el.clientHeight;
scrollContainerRef.current.scrollTop = 0;
}
store.clearStreamState();
try {
@@ -697,8 +696,7 @@ const AgentDetail: FC = () => {
clearChatErrorReason(agentId);
clearStreamError();
if (scrollContainerRef.current) {
const el = scrollContainerRef.current;
el.scrollTop = el.scrollHeight - el.clientHeight;
scrollContainerRef.current.scrollTop = 0;
}
// No optimistic rendering — the message will appear in the
@@ -1,227 +0,0 @@
import type { RefObject } from "react";
import { useEffect, useRef, useState } from "react";
const SCROLL_THRESHOLD = 100;
const isNearBottom = (container: HTMLElement): boolean => {
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
return distanceFromBottom <= SCROLL_THRESHOLD;
};
type RafIdRef = { current: number | null };
type BooleanRef = { current: boolean };
const cancelRaf = (rafIdRef: RafIdRef) => {
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
};
const cancelPendingPins = (
pinOuterRafIdRef: RafIdRef,
pinInnerRafIdRef: RafIdRef,
) => {
cancelRaf(pinOuterRafIdRef);
cancelRaf(pinInnerRafIdRef);
};
const cancelScrollStateUpdate = (scrollStateRafIdRef: RafIdRef) => {
cancelRaf(scrollStateRafIdRef);
};
const scheduleBottomPin = (
scrollContainerRef: RefObject<HTMLDivElement | null>,
autoScrollRef: BooleanRef,
pinOuterRafIdRef: RafIdRef,
pinInnerRafIdRef: RafIdRef,
) => {
const container = scrollContainerRef.current;
if (!container) return;
cancelPendingPins(pinOuterRafIdRef, pinInnerRafIdRef);
// Double-RAF ensures React's commit phase and the browser's
// layout pass both complete before pinning to bottom. The first
// RAF defers past React's commit; the second defers past the
// browser's layout calculation, guaranteeing scrollHeight is
// accurate.
pinOuterRafIdRef.current = requestAnimationFrame(() => {
pinOuterRafIdRef.current = null;
pinInnerRafIdRef.current = requestAnimationFrame(() => {
pinInnerRafIdRef.current = null;
const nextContainer = scrollContainerRef.current;
if (!nextContainer || !autoScrollRef.current) return;
nextContainer.scrollTop = Math.max(
nextContainer.scrollHeight - nextContainer.clientHeight,
0,
);
});
});
};
interface UseAgentTranscriptAutoScrollResult {
contentRef: RefObject<HTMLDivElement | null>;
showScrollToBottom: boolean;
jumpToBottom: () => void;
}
export function useAgentTranscriptAutoScroll(
scrollContainerRef: RefObject<HTMLDivElement | null>,
): UseAgentTranscriptAutoScrollResult {
const contentRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const isProgrammaticScrollRef = useRef(false);
const scrollStateRafIdRef = useRef<number | null>(null);
const pinOuterRafIdRef = useRef<number | null>(null);
const pinInnerRafIdRef = useRef<number | null>(null);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
const scheduleButtonStateUpdate = () => {
if (scrollStateRafIdRef.current !== null) return;
scrollStateRafIdRef.current = requestAnimationFrame(() => {
scrollStateRafIdRef.current = null;
const nextContainer = scrollContainerRef.current;
if (!nextContainer) return;
const shouldShow = !isNearBottom(nextContainer);
setShowScrollToBottom(shouldShow);
});
};
const handleScroll = () => {
const nearBottom = isNearBottom(container);
if (isProgrammaticScrollRef.current) {
if (nearBottom) {
isProgrammaticScrollRef.current = false;
autoScrollRef.current = true;
cancelScrollStateUpdate(scrollStateRafIdRef);
setShowScrollToBottom(false);
}
return;
}
autoScrollRef.current = nearBottom;
scheduleButtonStateUpdate();
};
const handleUserInterrupt = () => {
isProgrammaticScrollRef.current = false;
};
container.addEventListener("scroll", handleScroll, { passive: true });
container.addEventListener("wheel", handleUserInterrupt, { passive: true });
container.addEventListener("touchstart", handleUserInterrupt, {
passive: true,
});
scheduleBottomPin(
scrollContainerRef,
autoScrollRef,
pinOuterRafIdRef,
pinInnerRafIdRef,
);
return () => {
container.removeEventListener("scroll", handleScroll);
container.removeEventListener("wheel", handleUserInterrupt);
container.removeEventListener("touchstart", handleUserInterrupt);
};
}, [scrollContainerRef]);
useEffect(() => {
const container = scrollContainerRef.current;
const content = contentRef.current;
if (!container || !content) return;
const initialRect = content.getBoundingClientRect();
let prevContentHeight = initialRect.height;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
const nextHeight =
entry?.contentRect.height ?? content.getBoundingClientRect().height;
const heightDelta = nextHeight - prevContentHeight;
prevContentHeight = nextHeight;
if (Math.abs(heightDelta) < 1 || !autoScrollRef.current) {
return;
}
scheduleBottomPin(
scrollContainerRef,
autoScrollRef,
pinOuterRafIdRef,
pinInnerRafIdRef,
);
});
observer.observe(content);
return () => {
observer.disconnect();
};
}, [scrollContainerRef]);
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
let prevContainerHeight = container.clientHeight;
const observer = new ResizeObserver((entries) => {
const nextHeight =
entries[0]?.contentRect.height ?? container.clientHeight;
const heightDelta = nextHeight - prevContainerHeight;
prevContainerHeight = nextHeight;
if (Math.abs(heightDelta) < 1 || !autoScrollRef.current) {
return;
}
scheduleBottomPin(
scrollContainerRef,
autoScrollRef,
pinOuterRafIdRef,
pinInnerRafIdRef,
);
});
observer.observe(container);
return () => {
observer.disconnect();
};
}, [scrollContainerRef]);
useEffect(() => {
return () => {
cancelPendingPins(pinOuterRafIdRef, pinInnerRafIdRef);
cancelScrollStateUpdate(scrollStateRafIdRef);
};
}, []);
const jumpToBottom = () => {
const container = scrollContainerRef.current;
if (!container) return;
autoScrollRef.current = true;
isProgrammaticScrollRef.current = true;
cancelScrollStateUpdate(scrollStateRafIdRef);
setShowScrollToBottom(false);
container.scrollTo({
top: container.scrollHeight - container.clientHeight,
behavior: "smooth",
});
};
return {
contentRef,
showScrollToBottom,
jumpToBottom,
};
}
@@ -506,17 +506,14 @@ const waitForScrollOverflow = async (scrollContainer: HTMLElement) => {
};
const scrollAwayFromBottom = (scrollContainer: HTMLElement) => {
// Normal order: scrollTop = 0 is top, scrollTop =
// scrollHeight - clientHeight is bottom. Set to top to get
// maximally away from bottom.
scrollContainer.scrollTop = 0;
const maxScroll = scrollContainer.scrollHeight - scrollContainer.clientHeight;
scrollContainer.scrollTop = -maxScroll;
if (Math.abs(scrollContainer.scrollTop) < 100) {
scrollContainer.scrollTop = maxScroll;
}
scrollContainer.dispatchEvent(new Event("scroll"));
};
/** Distance in pixels from the bottom of a scroll container. */
const distFromBottom = (el: HTMLElement): number =>
el.scrollHeight - el.scrollTop - el.clientHeight;
/** Helper that extracts the current messages array from a store. */
const getStoreMessages = (
store: ReturnType<typeof createChatStore>,
@@ -556,7 +553,10 @@ export const ScrollToBottomButton: Story = {
// Wait for content to render and create overflow.
await waitForScrollOverflow(scrollContainer);
// Scroll away from the bottom.
// 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.
scrollAwayFromBottom(scrollContainer);
// Button should become visible (enters the accessibility tree).
@@ -610,9 +610,7 @@ export const ScrollPositionPreservedOnNewContent: Story = {
// Record position while clearly away from the bottom.
const scrollTopBefore = scrollContainer.scrollTop;
const distFromBottomBefore = distFromBottom(scrollContainer);
expect(scrollTopBefore).toBeLessThan(5);
expect(distFromBottomBefore).toBeGreaterThan(50);
expect(Math.abs(scrollTopBefore)).toBeGreaterThan(50);
const existing = getStoreMessages(preservedScrollStore);
preservedScrollStore.replaceMessages(
@@ -630,19 +628,11 @@ export const ScrollPositionPreservedOnNewContent: Story = {
]),
);
// Wait for ResizeObserver updates to settle. We should
// remain significantly away from the bottom and keep the
// same reading position.
// Wait for ResizeObserver + RAF compensation to settle.
// We should remain significantly away from the bottom.
await waitFor(
() => {
const distanceFromBottom = distFromBottom(scrollContainer);
expect(scrollContainer.scrollTop).toBeGreaterThanOrEqual(
scrollTopBefore - 5,
);
expect(scrollContainer.scrollTop).toBeLessThanOrEqual(
scrollTopBefore + 5,
);
expect(distanceFromBottom).toBeGreaterThan(50);
expect(Math.abs(scrollContainer.scrollTop)).toBeGreaterThan(50);
},
{ timeout: 2000 },
);
@@ -665,14 +655,8 @@ export const ScrollPinnedToBottomOnNewContent: Story = {
await waitForScrollOverflow(scrollContainer);
// Wait for the initial double-RAF pin to bottom to complete.
await waitFor(
() => {
const initialDistFromBottom = distFromBottom(scrollContainer);
expect(initialDistFromBottom).toBeLessThan(5);
},
{ timeout: 2000 },
);
// Verify the starting position is pinned to the bottom.
expect(Math.abs(scrollContainer.scrollTop)).toBeLessThan(5);
expect(
canvas.queryByRole("button", { name: "Scroll to bottom" }),
).toBeNull();
@@ -694,8 +678,7 @@ export const ScrollPinnedToBottomOnNewContent: Story = {
// Wait for the double-RAF pin to complete.
await waitFor(
() => {
const distanceFromBottom = distFromBottom(scrollContainer);
expect(distanceFromBottom).toBeLessThan(5);
expect(Math.abs(scrollContainer.scrollTop)).toBeLessThan(5);
},
{ timeout: 2000 },
);
@@ -11,7 +11,6 @@ import type { ChatDetailError } from "../utils/usageLimitMessage";
import { AgentChatInput, type ChatMessageInputRef } from "./AgentChatInput";
import type { useChatStore } from "./AgentDetail/ChatContext";
import { AgentDetailTopBar } from "./AgentDetail/TopBar";
import { useAgentTranscriptAutoScroll } from "./AgentDetail/useAgentTranscriptAutoScroll";
import { AgentDetailInput, AgentDetailTimeline } from "./AgentDetailContent";
import {
ChatConversationSkeleton,
@@ -407,7 +406,7 @@ export const AgentDetailLoadingView: FC<AgentDetailLoadingViewProps> = ({
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto [scrollbar-gutter:stable] [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]">
<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="px-4">
<div className="mx-auto w-full max-w-3xl py-6">
<ChatConversationSkeleton />
@@ -486,6 +485,31 @@ 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).
*
* Handles:
* - Loading older message pages via an IntersectionObserver sentinel.
* - ResizeObserver-driven scroll anchoring for transcript and viewport
* size changes.
* - 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.
*/
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;
}
const ScrollAnchoredContainer: FC<{
scrollContainerRef: RefObject<HTMLDivElement | null>;
isFetchingMoreMessages: boolean;
@@ -503,14 +527,18 @@ const ScrollAnchoredContainer: FC<{
const observerRef = useRef<IntersectionObserver | null>(null);
const isFetchingRef = useRef(isFetchingMoreMessages);
const onFetchRef = useRef(onFetchMoreMessages);
const prevScrollHeightRef = useRef(0);
const { contentRef, showScrollToBottom, jumpToBottom } =
useAgentTranscriptAutoScroll(scrollContainerRef);
const autoScrollRef = useRef(true);
const contentRef = useRef<HTMLDivElement>(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(() => {
isFetchingRef.current = isFetchingMoreMessages;
onFetchRef.current = onFetchMoreMessages;
}, [isFetchingMoreMessages, onFetchMoreMessages]);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
// Sentinel observer — triggers loading older messages.
// All changing values are read from refs so the observer
@@ -555,45 +583,244 @@ const ScrollAnchoredContainer: FC<{
observer.observe(sentinel);
}, [isFetchingMoreMessages]);
// Pagination prepend: preserve scroll position when older
// messages are loaded. Snapshot scrollHeight once at fetch
// start, then compensate by the delta when the fetch
// completes.
useEffect(() => {
if (isFetchingMoreMessages) {
prevScrollHeightRef.current =
scrollContainerRef.current?.scrollHeight ?? 0;
return;
}
const container = scrollContainerRef.current;
const prevScrollHeight = prevScrollHeightRef.current;
if (!container || prevScrollHeight === 0) return;
prevScrollHeightRef.current = 0;
const content = contentRef.current;
if (!container || !content) return;
const rafId = requestAnimationFrame(() => {
const nextContainer = scrollContainerRef.current;
if (!nextContainer) return;
const delta = nextContainer.scrollHeight - prevScrollHeight;
if (delta > 0) {
nextContainer.scrollTop += delta;
const initialContentRect = content.getBoundingClientRect();
let prevContentHeight = initialContentRect.height;
let prevContentWidth = initialContentRect.width;
let pinOuterRafId: number | null = null;
let pinInnerRafId: number | null = null;
let restoreGuardRafId: number | null = null;
const cancelPendingPins = () => {
if (pinOuterRafId !== null) {
cancelAnimationFrame(pinOuterRafId);
}
if (pinInnerRafId !== null) {
cancelAnimationFrame(pinInnerRafId);
}
pinOuterRafId = null;
pinInnerRafId = null;
};
const scheduleBottomPin = () => {
cancelPendingPins();
isRestoringScrollRef.current = true;
// Double-RAF lets React's commit phase and the browser's
// layout pass both complete before we pin to bottom.
pinOuterRafId = requestAnimationFrame(() => {
pinOuterRafId = null;
pinInnerRafId = requestAnimationFrame(() => {
pinInnerRafId = null;
if (!autoScrollRef.current) {
isRestoringScrollRef.current = false;
return;
}
if (restoreGuardRafId !== null) {
cancelAnimationFrame(restoreGuardRafId);
}
container.scrollTop = 0;
restoreGuardRafId = requestAnimationFrame(() => {
isRestoringScrollRef.current = false;
restoreGuardRafId = null;
});
});
});
};
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;
});
};
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
const nextHeight =
entry?.contentRect.height ?? content.getBoundingClientRect().height;
const nextWidth =
entry?.contentRect.width ?? content.getBoundingClientRect().width;
const delta = nextHeight - prevContentHeight;
const widthChanged = Math.abs(nextWidth - prevContentWidth) > 1;
prevContentHeight = nextHeight;
prevContentWidth = nextWidth;
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) {
return;
}
// Skip compensation during reflow. Width changes indicate the
// height delta is distributed through the transcript rather than
// appended at the bottom, so applying the full delta would
// overcompensate and jump the user.
if (widthChanged) {
return;
}
if (autoScrollRef.current) {
scheduleBottomPin();
return;
}
compensateScroll(delta);
});
observer.observe(content);
return () => {
cancelAnimationFrame(rafId);
observer.disconnect();
cancelPendingPins();
if (restoreGuardRafId !== null) {
cancelAnimationFrame(restoreGuardRafId);
}
isRestoringScrollRef.current = false;
};
}, [isFetchingMoreMessages, scrollContainerRef]);
}, [scrollContainerRef]);
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
let prevContainerHeight = container.clientHeight;
let restoreGuardRafId: number | null = null;
const observer = new ResizeObserver((entries) => {
const nextHeight =
entries[0]?.contentRect.height ?? container.clientHeight;
const delta = nextHeight - prevContainerHeight;
prevContainerHeight = nextHeight;
if (Math.abs(delta) < 1 || !autoScrollRef.current) {
return;
}
if (restoreGuardRafId !== null) {
cancelAnimationFrame(restoreGuardRafId);
}
isRestoringScrollRef.current = true;
container.scrollTop = 0;
restoreGuardRafId = requestAnimationFrame(() => {
isRestoringScrollRef.current = false;
restoreGuardRafId = null;
});
});
observer.observe(container);
return () => {
observer.disconnect();
if (restoreGuardRafId !== null) {
cancelAnimationFrame(restoreGuardRafId);
}
isRestoringScrollRef.current = false;
};
}, [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;
let rafId: number | null = null;
const handleScroll = () => {
// While a programmatic scroll is in progress (e.g. smooth
// scroll-to-bottom), suppress normal handling. Clear the
// guard once the scroll reaches the bottom so normal
// tracking resumes. User-input interruptions are handled
// separately via wheel/touchstart listeners.
if (isRestoringScrollRef.current) {
if (isNearBottom(container)) {
isRestoringScrollRef.current = false;
autoScrollRef.current = true;
}
return;
}
const nearBottom = isNearBottom(container);
autoScrollRef.current = nearBottom;
// Throttle the button visibility state update to once per
// frame. This is the only part that triggers a re-render.
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
setShowScrollToBottom((prev) => {
const shouldShow = !isNearBottom(container);
return prev === shouldShow ? prev : shouldShow;
});
rafId = null;
});
};
const handleUserInterrupt = () => {
if (isRestoringScrollRef.current) {
isRestoringScrollRef.current = false;
}
};
container.addEventListener("scroll", handleScroll, { passive: true });
container.addEventListener("wheel", handleUserInterrupt, {
passive: true,
});
container.addEventListener("touchstart", handleUserInterrupt, {
passive: true,
});
return () => {
container.removeEventListener("scroll", handleScroll);
container.removeEventListener("wheel", handleUserInterrupt);
container.removeEventListener("touchstart", handleUserInterrupt);
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
};
}, [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);
};
return (
<div className="relative flex min-h-0 flex-1 flex-col">
<div
ref={scrollContainerRef}
data-testid="scroll-container"
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]"
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]"
>
{hasMoreMessages && <div ref={sentinelRef} className="h-px shrink-0" />}
<div ref={contentRef}>{children}</div>
{hasMoreMessages && <div ref={sentinelRef} className="h-px shrink-0" />}
</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
@@ -605,7 +832,7 @@ const ScrollAnchoredContainer: FC<{
? "pointer-events-auto translate-y-0 opacity-100"
: "translate-y-2 opacity-0",
)}
onClick={jumpToBottom}
onClick={handleScrollToBottom}
aria-label="Scroll to bottom"
aria-hidden={!showScrollToBottom || undefined}
tabIndex={showScrollToBottom ? undefined : -1}