mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix: revert "refactor(site/src/pages/AgentsPage): normalize transcript scrolling" (#23638)
Reverts coder/coder#23576
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user