mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(site/src/pages/AgentsPage): preserve chat scroll position when away from bottom (#23451)
## Summary Stabilizes the /agents chat viewport so users can read older messages without being yanked to the bottom when new content arrives. ## Architecture Replaces the implicit scroll-follow behavior with **ResizeObserver-driven scroll anchoring**: - **`autoScrollRef`** is the single source of truth. User scrolling away from bottom turns it off; scrolling back near bottom or clicking the button turns it back on. - A **content ResizeObserver** on an inner wrapper detects transcript growth. When auto-scroll is on, it re-pins to bottom via double-RAF (waiting for React commit + layout to settle). When off, it compensates `scrollTop` by the height delta to preserve the reading position. Sign-aware for both Chrome-style negative and Firefox-style positive `flex-col-reverse` scrollTop conventions. - Compensation is **skipped during pagination** (older messages prepend into the overflow direction; the browser preserves scrollTop) and **during reflow** from width changes. - A **container ResizeObserver** re-pins to bottom after viewport resizes (composer growth, panel changes) when auto-scroll is on. - **`isRestoringScrollRef`** guards against feedback loops from programmatic scroll writes. The smooth-scroll guard stays active until the scroll handler detects arrival at bottom. ## Files changed - **AgentDetailView.tsx**: Rewrote `ScrollAnchoredContainer` with the new approach. - **AgentDetailView.stories.tsx**: Refactored `ScrollToBottomButton` story scroll helpers into shared utilities. ## Behavior - **At bottom + new content**: stays pinned, button hidden. - **Scrolled up + new content**: reading position preserved, no jump. - **Viewport resize while pinned**: re-pins to bottom. - Scroll-to-bottom button and smooth scroll still work.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { MockUserOwner } from "testHelpers/entities";
|
import { MockUserOwner } from "testHelpers/entities";
|
||||||
import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook";
|
import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook";
|
||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { API } from "api/api";
|
import { API } from "api/api";
|
||||||
import type * as TypesGen from "api/typesGenerated";
|
import type * as TypesGen from "api/typesGenerated";
|
||||||
import type { ChatDiffStatus, ChatMessagePart } from "api/typesGenerated";
|
import type { ChatDiffStatus, ChatMessagePart } from "api/typesGenerated";
|
||||||
@@ -453,25 +453,44 @@ const buildLongConversation = (count: number): TypesGen.ChatMessage[] => {
|
|||||||
return messages;
|
return messages;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const scrollStoryDecorators: Decorator[] = [
|
||||||
|
(Story) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "600px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const waitForScrollOverflow = async (scrollContainer: HTMLElement) => {
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(scrollContainer.scrollHeight).toBeGreaterThan(
|
||||||
|
scrollContainer.clientHeight,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollAwayFromBottom = (scrollContainer: HTMLElement) => {
|
||||||
|
const maxScroll = scrollContainer.scrollHeight - scrollContainer.clientHeight;
|
||||||
|
scrollContainer.scrollTop = -maxScroll;
|
||||||
|
if (Math.abs(scrollContainer.scrollTop) < 100) {
|
||||||
|
scrollContainer.scrollTop = maxScroll;
|
||||||
|
}
|
||||||
|
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||||
|
};
|
||||||
|
|
||||||
/** Scroll-to-bottom button appears after scrolling up in a long
|
/** Scroll-to-bottom button appears after scrolling up in a long
|
||||||
* conversation, and clicking it returns to the bottom. */
|
* conversation, and clicking it returns to the bottom. */
|
||||||
export const ScrollToBottomButton: Story = {
|
export const ScrollToBottomButton: Story = {
|
||||||
args: {
|
args: {
|
||||||
store: buildStoreWithMessages(buildLongConversation(40)),
|
store: buildStoreWithMessages(buildLongConversation(40)),
|
||||||
},
|
},
|
||||||
decorators: [
|
decorators: scrollStoryDecorators,
|
||||||
(Story) => (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "600px",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Story />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
],
|
|
||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(canvasElement);
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
@@ -485,23 +504,13 @@ export const ScrollToBottomButton: Story = {
|
|||||||
const scrollContainer = canvas.getByTestId("scroll-container");
|
const scrollContainer = canvas.getByTestId("scroll-container");
|
||||||
|
|
||||||
// Wait for content to render and create overflow.
|
// Wait for content to render and create overflow.
|
||||||
await waitFor(() => {
|
await waitForScrollOverflow(scrollContainer);
|
||||||
expect(scrollContainer.scrollHeight).toBeGreaterThan(
|
|
||||||
scrollContainer.clientHeight,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scroll up. In flex-col-reverse containers, Chrome uses
|
// Scroll up. In flex-col-reverse containers, Chrome uses
|
||||||
// negative scrollTop values when scrolled away from the
|
// negative scrollTop values when scrolled away from the
|
||||||
// bottom. Try negative first, fall back to positive for
|
// bottom. Try negative first, fall back to positive for
|
||||||
// other engines.
|
// other engines.
|
||||||
const maxScroll =
|
scrollAwayFromBottom(scrollContainer);
|
||||||
scrollContainer.scrollHeight - scrollContainer.clientHeight;
|
|
||||||
scrollContainer.scrollTop = -maxScroll;
|
|
||||||
if (Math.abs(scrollContainer.scrollTop) < 100) {
|
|
||||||
scrollContainer.scrollTop = maxScroll;
|
|
||||||
}
|
|
||||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
|
||||||
|
|
||||||
// Button should become visible (enters the accessibility tree).
|
// Button should become visible (enters the accessibility tree).
|
||||||
const button = await waitFor(() => {
|
const button = await waitFor(() => {
|
||||||
@@ -522,3 +531,129 @@ export const ScrollToBottomButton: Story = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** When scrolled away from bottom, new content preserves scroll position. */
|
||||||
|
export const ScrollPositionPreservedOnNewContent: Story = {
|
||||||
|
args: {
|
||||||
|
store: buildStoreWithMessages(buildLongConversation(30)),
|
||||||
|
},
|
||||||
|
decorators: scrollStoryDecorators,
|
||||||
|
play: async ({ canvasElement, args }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
const scrollContainer = canvas.getByTestId("scroll-container");
|
||||||
|
|
||||||
|
await waitForScrollOverflow(scrollContainer);
|
||||||
|
|
||||||
|
// Scroll away from bottom.
|
||||||
|
scrollAwayFromBottom(scrollContainer);
|
||||||
|
|
||||||
|
// Wait for the button to confirm we are away from the bottom.
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(
|
||||||
|
canvas.getByRole("button", { name: "Scroll to bottom" }),
|
||||||
|
).toBeVisible();
|
||||||
|
},
|
||||||
|
{ timeout: 2000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Record position while clearly away from the bottom.
|
||||||
|
const scrollTopBefore = scrollContainer.scrollTop;
|
||||||
|
expect(Math.abs(scrollTopBefore)).toBeGreaterThan(50);
|
||||||
|
|
||||||
|
const store = args?.store;
|
||||||
|
if (!store) {
|
||||||
|
throw new Error("Expected store in args");
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = store.getSnapshot();
|
||||||
|
const existing: TypesGen.ChatMessage[] = [];
|
||||||
|
for (const id of snapshot.orderedMessageIDs) {
|
||||||
|
const message = snapshot.messagesByID.get(id);
|
||||||
|
if (message) {
|
||||||
|
existing.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMessages = [
|
||||||
|
buildMessage(31, "user", "Follow-up question about the implementation."),
|
||||||
|
buildMessage(
|
||||||
|
32,
|
||||||
|
"assistant",
|
||||||
|
"Here is a detailed response about the implementation details you asked about.",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
store.replaceMessages(existing.concat(newMessages));
|
||||||
|
|
||||||
|
// Wait for ResizeObserver + RAF compensation to settle.
|
||||||
|
// We should remain significantly away from the bottom.
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(Math.abs(scrollContainer.scrollTop)).toBeGreaterThan(50);
|
||||||
|
},
|
||||||
|
{ timeout: 2000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
canvas.getByRole("button", { name: "Scroll to bottom" }),
|
||||||
|
).toBeVisible();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** When at bottom, new content keeps the user pinned to bottom. */
|
||||||
|
export const ScrollPinnedToBottomOnNewContent: Story = {
|
||||||
|
args: {
|
||||||
|
store: buildStoreWithMessages(buildLongConversation(30)),
|
||||||
|
},
|
||||||
|
decorators: scrollStoryDecorators,
|
||||||
|
play: async ({ canvasElement, args }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
const scrollContainer = canvas.getByTestId("scroll-container");
|
||||||
|
|
||||||
|
await waitForScrollOverflow(scrollContainer);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
const store = args?.store;
|
||||||
|
if (!store) {
|
||||||
|
throw new Error("Expected store in args");
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = store.getSnapshot();
|
||||||
|
const existing: TypesGen.ChatMessage[] = [];
|
||||||
|
for (const id of snapshot.orderedMessageIDs) {
|
||||||
|
const message = snapshot.messagesByID.get(id);
|
||||||
|
if (message) {
|
||||||
|
existing.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMessages = [
|
||||||
|
buildMessage(31, "user", "Another question."),
|
||||||
|
buildMessage(32, "assistant", "Here is the answer with full details."),
|
||||||
|
buildMessage(33, "user", "Thanks, one more thing."),
|
||||||
|
buildMessage(
|
||||||
|
34,
|
||||||
|
"assistant",
|
||||||
|
"Sure, here is the additional information you requested.",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
store.replaceMessages(existing.concat(newMessages));
|
||||||
|
|
||||||
|
// Wait for the double-RAF pin to complete.
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(Math.abs(scrollContainer.scrollTop)).toBeLessThan(5);
|
||||||
|
},
|
||||||
|
{ timeout: 2000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
canvas.queryByRole("button", { name: "Scroll to bottom" }),
|
||||||
|
).toBeNull();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -503,13 +503,29 @@ export const AgentDetailNotFoundView: FC<AgentDetailNotFoundViewProps> = ({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Scroll container that uses flex-col-reverse for bottom-anchored chat
|
* Scroll container that uses flex-col-reverse for bottom-anchored chat
|
||||||
* layout. Handles loading older message pages via an IntersectionObserver
|
* layout. In this layout scrollTop = 0 means the user is at the
|
||||||
* sentinel and manually restores scroll position after new content
|
* bottom (most recent content); scrolling up moves scrollTop away from
|
||||||
* renders — CSS scroll anchoring is unreliable in flex-col-reverse
|
* 0 (negative in Chrome, positive in Firefox).
|
||||||
* containers.
|
*
|
||||||
|
* 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;
|
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<{
|
const ScrollAnchoredContainer: FC<{
|
||||||
scrollContainerRef: RefObject<HTMLDivElement | null>;
|
scrollContainerRef: RefObject<HTMLDivElement | null>;
|
||||||
isFetchingMoreMessages: boolean;
|
isFetchingMoreMessages: boolean;
|
||||||
@@ -527,6 +543,13 @@ const ScrollAnchoredContainer: FC<{
|
|||||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||||
const isFetchingRef = useRef(isFetchingMoreMessages);
|
const isFetchingRef = useRef(isFetchingMoreMessages);
|
||||||
const onFetchRef = useRef(onFetchMoreMessages);
|
const onFetchRef = useRef(onFetchMoreMessages);
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
isFetchingRef.current = isFetchingMoreMessages;
|
isFetchingRef.current = isFetchingMoreMessages;
|
||||||
onFetchRef.current = onFetchMoreMessages;
|
onFetchRef.current = onFetchMoreMessages;
|
||||||
@@ -576,13 +599,164 @@ const ScrollAnchoredContainer: FC<{
|
|||||||
observer.observe(sentinel);
|
observer.observe(sentinel);
|
||||||
}, [isFetchingMoreMessages]);
|
}, [isFetchingMoreMessages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = scrollContainerRef.current;
|
||||||
|
const content = contentRef.current;
|
||||||
|
if (!container || !content) return;
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
observer.disconnect();
|
||||||
|
cancelPendingPins();
|
||||||
|
if (restoreGuardRafId !== null) {
|
||||||
|
cancelAnimationFrame(restoreGuardRafId);
|
||||||
|
}
|
||||||
|
isRestoringScrollRef.current = false;
|
||||||
|
};
|
||||||
|
}, [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.
|
// Track scroll position to show/hide the scroll-to-bottom button.
|
||||||
// In a flex-col-reverse container, scrollTop = 0 means the user
|
// In a flex-col-reverse container, scrollTop = 0 means the user
|
||||||
// is at the bottom (most recent content). Scrolling up to see
|
// is at the bottom (most recent content). Scrolling up moves
|
||||||
// older messages makes scrollTop negative.
|
// scrollTop away from 0, with the sign varying by engine.
|
||||||
//
|
|
||||||
// Throttled to once per animation frame so we avoid calling
|
|
||||||
// setState on every high-frequency scroll event.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = scrollContainerRef.current;
|
const container = scrollContainerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -590,19 +764,51 @@ const ScrollAnchoredContainer: FC<{
|
|||||||
let rafId: number | null = null;
|
let rafId: number | null = null;
|
||||||
|
|
||||||
const handleScroll = () => {
|
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;
|
if (rafId !== null) return;
|
||||||
rafId = requestAnimationFrame(() => {
|
rafId = requestAnimationFrame(() => {
|
||||||
const shouldShow = Math.abs(container.scrollTop) >= SCROLL_THRESHOLD;
|
setShowScrollToBottom((prev) => {
|
||||||
setShowScrollToBottom((prev) =>
|
const shouldShow = !isNearBottom(container);
|
||||||
prev === shouldShow ? prev : shouldShow,
|
return prev === shouldShow ? prev : shouldShow;
|
||||||
);
|
});
|
||||||
rafId = null;
|
rafId = null;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUserInterrupt = () => {
|
||||||
|
if (isRestoringScrollRef.current) {
|
||||||
|
isRestoringScrollRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
container.addEventListener("scroll", handleScroll, { passive: true });
|
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
container.addEventListener("wheel", handleUserInterrupt, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
container.addEventListener("touchstart", handleUserInterrupt, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
container.removeEventListener("scroll", handleScroll);
|
container.removeEventListener("scroll", handleScroll);
|
||||||
|
container.removeEventListener("wheel", handleUserInterrupt);
|
||||||
|
container.removeEventListener("touchstart", handleUserInterrupt);
|
||||||
if (rafId !== null) {
|
if (rafId !== null) {
|
||||||
cancelAnimationFrame(rafId);
|
cancelAnimationFrame(rafId);
|
||||||
}
|
}
|
||||||
@@ -612,9 +818,11 @@ const ScrollAnchoredContainer: FC<{
|
|||||||
const handleScrollToBottom = () => {
|
const handleScrollToBottom = () => {
|
||||||
const container = scrollContainerRef.current;
|
const container = scrollContainerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
autoScrollRef.current = true;
|
||||||
|
isRestoringScrollRef.current = true;
|
||||||
container.scrollTo({ top: 0, behavior: "smooth" });
|
container.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
// Hide immediately so the button doesn't linger while the
|
// Hide immediately so the button doesn't linger while the
|
||||||
// smooth scroll animates. If the user interrupts the scroll
|
// smooth scroll animates. If the user interrupts the scroll
|
||||||
// before it reaches the bottom, the scroll handler will
|
// before it reaches the bottom, the scroll handler will
|
||||||
// re-show the button.
|
// re-show the button.
|
||||||
setShowScrollToBottom(false);
|
setShowScrollToBottom(false);
|
||||||
@@ -627,7 +835,7 @@ const ScrollAnchoredContainer: FC<{
|
|||||||
data-testid="scroll-container"
|
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-reverse overflow-y-auto [overflow-anchor:none] [scrollbar-gutter:stable] [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]"
|
||||||
>
|
>
|
||||||
{children}
|
<div ref={contentRef}>{children}</div>
|
||||||
{hasMoreMessages && <div ref={sentinelRef} className="h-px shrink-0" />}
|
{hasMoreMessages && <div ref={sentinelRef} className="h-px shrink-0" />}
|
||||||
</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]">
|
<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]">
|
||||||
|
|||||||
Reference in New Issue
Block a user