mirror of
https://github.com/coder/coder.git
synced 2026-06-06 14:38:23 +00: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 { 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 type * as TypesGen from "api/typesGenerated";
|
||||
import type { ChatDiffStatus, ChatMessagePart } from "api/typesGenerated";
|
||||
@@ -453,25 +453,44 @@ const buildLongConversation = (count: number): TypesGen.ChatMessage[] => {
|
||||
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
|
||||
* conversation, and clicking it returns to the bottom. */
|
||||
export const ScrollToBottomButton: Story = {
|
||||
args: {
|
||||
store: buildStoreWithMessages(buildLongConversation(40)),
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div
|
||||
style={{
|
||||
height: "600px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
decorators: scrollStoryDecorators,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
@@ -485,23 +504,13 @@ export const ScrollToBottomButton: Story = {
|
||||
const scrollContainer = canvas.getByTestId("scroll-container");
|
||||
|
||||
// Wait for content to render and create overflow.
|
||||
await waitFor(() => {
|
||||
expect(scrollContainer.scrollHeight).toBeGreaterThan(
|
||||
scrollContainer.clientHeight,
|
||||
);
|
||||
});
|
||||
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.
|
||||
const maxScroll =
|
||||
scrollContainer.scrollHeight - scrollContainer.clientHeight;
|
||||
scrollContainer.scrollTop = -maxScroll;
|
||||
if (Math.abs(scrollContainer.scrollTop) < 100) {
|
||||
scrollContainer.scrollTop = maxScroll;
|
||||
}
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
scrollAwayFromBottom(scrollContainer);
|
||||
|
||||
// Button should become visible (enters the accessibility tree).
|
||||
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
|
||||
* layout. Handles loading older message pages via an IntersectionObserver
|
||||
* sentinel and manually restores scroll position after new content
|
||||
* renders — CSS scroll anchoring is unreliable in flex-col-reverse
|
||||
* containers.
|
||||
* 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;
|
||||
@@ -527,6 +543,13 @@ const ScrollAnchoredContainer: FC<{
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const isFetchingRef = useRef(isFetchingMoreMessages);
|
||||
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(() => {
|
||||
isFetchingRef.current = isFetchingMoreMessages;
|
||||
onFetchRef.current = onFetchMoreMessages;
|
||||
@@ -576,13 +599,164 @@ const ScrollAnchoredContainer: FC<{
|
||||
observer.observe(sentinel);
|
||||
}, [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.
|
||||
// In a flex-col-reverse container, scrollTop = 0 means the user
|
||||
// is at the bottom (most recent content). Scrolling up to see
|
||||
// older messages makes scrollTop negative.
|
||||
//
|
||||
// Throttled to once per animation frame so we avoid calling
|
||||
// setState on every high-frequency scroll event.
|
||||
// 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;
|
||||
@@ -590,19 +764,51 @@ const ScrollAnchoredContainer: FC<{
|
||||
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(() => {
|
||||
const shouldShow = Math.abs(container.scrollTop) >= SCROLL_THRESHOLD;
|
||||
setShowScrollToBottom((prev) =>
|
||||
prev === shouldShow ? prev : shouldShow,
|
||||
);
|
||||
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);
|
||||
}
|
||||
@@ -612,9 +818,11 @@ const ScrollAnchoredContainer: FC<{
|
||||
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
|
||||
// smooth scroll animates. If the user interrupts the scroll
|
||||
// before it reaches the bottom, the scroll handler will
|
||||
// re-show the button.
|
||||
setShowScrollToBottom(false);
|
||||
@@ -627,7 +835,7 @@ const ScrollAnchoredContainer: FC<{
|
||||
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]"
|
||||
>
|
||||
{children}
|
||||
<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]">
|
||||
|
||||
Reference in New Issue
Block a user