From 73bde99495449048cb8effa3173bbcbac866473d Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Sat, 28 Mar 2026 16:24:12 +0100
Subject: [PATCH] fix(site): prevent mobile scroll jump during active touch
gestures (#23734)
---
.../components/AgentDetailView.stories.tsx | 270 ++++++++++++++++++
.../AgentsPage/components/AgentDetailView.tsx | 157 +++++++++-
2 files changed, 422 insertions(+), 5 deletions(-)
diff --git a/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx b/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx
index 54e6fcd5b4..67979b39a1 100644
--- a/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx
+++ b/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx
@@ -751,3 +751,273 @@ export const ScrollPinnedToBottomOnNewContent: Story = {
).toBeNull();
},
};
+
+const dispatchTouchEvent = (
+ scrollContainer: HTMLElement,
+ type: "touchstart" | "touchend",
+ changedTouchesLength: number,
+) => {
+ const event = new Event(type, { bubbles: true });
+ Object.defineProperty(event, "changedTouches", {
+ configurable: true,
+ value: Array.from({ length: changedTouchesLength }, (_, index) => ({
+ identifier: index,
+ })),
+ });
+ scrollContainer.dispatchEvent(event);
+};
+
+const touchGuardScrollStore = buildStoreWithMessages(buildLongConversation(30));
+
+/** During an active touch gesture, the container ResizeObserver must not
+ * snap scroll to bottom. This prevents the mobile URL bar resize jump. */
+export const ScrollNotJumpedDuringTouch: Story = {
+ decorators: scrollStoryDecorators,
+ render: () => ,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const scrollContainer = canvas.getByTestId("scroll-container");
+
+ await waitForScrollOverflow(scrollContainer);
+
+ // Wait for the initial bottom pin to settle.
+ await waitFor(
+ () => {
+ const dist =
+ scrollContainer.scrollHeight -
+ scrollContainer.scrollTop -
+ scrollContainer.clientHeight;
+ expect(dist).toBeLessThan(5);
+ },
+ { timeout: 2000 },
+ );
+ await new Promise((resolve) =>
+ requestAnimationFrame(() => resolve()),
+ );
+
+ // Simulate a multi-touch gesture starting with two fingers down.
+ dispatchTouchEvent(scrollContainer, "touchstart", 2);
+
+ // Scroll partway up, within the 100px threshold but not at the
+ // absolute bottom. This simulates the user dragging up slightly
+ // during a touch.
+ const offsetFromBottom = 50;
+ const targetScrollTop =
+ scrollContainer.scrollHeight -
+ scrollContainer.clientHeight -
+ offsetFromBottom;
+ scrollContainer.scrollTop = targetScrollTop;
+ scrollContainer.dispatchEvent(new Event("scroll"));
+
+ const originalHeight = scrollContainer.clientHeight;
+ const shrunkHeight = originalHeight - 10;
+
+ // Record the scroll position before the first resize.
+ const scrollTopBeforeFirstResize = scrollContainer.scrollTop;
+
+ // Simulate a container resize that models the mobile URL bar
+ // appearing. Shrink the container height slightly to trigger
+ // the ResizeObserver.
+ scrollContainer.style.height = `${shrunkHeight}px`;
+
+ // Give the ResizeObserver a chance to fire.
+ await new Promise((resolve) =>
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
+ );
+
+ // During an active touch, the resize guard should prevent the
+ // container observer from snapping to the absolute bottom.
+ expect(scrollContainer.scrollTop).toBeLessThanOrEqual(
+ scrollTopBeforeFirstResize + 1,
+ );
+
+ // Lift one finger, leaving a second touch active. The guard should
+ // still block resize snaps until the final finger is lifted.
+ dispatchTouchEvent(scrollContainer, "touchend", 1);
+ const scrollTopBeforeSecondResize = scrollContainer.scrollTop;
+ scrollContainer.style.height = `${originalHeight}px`;
+
+ await new Promise((resolve) =>
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
+ );
+
+ expect(scrollContainer.scrollTop).toBeLessThanOrEqual(
+ scrollTopBeforeSecondResize + 1,
+ );
+
+ // End the remaining touch.
+ dispatchTouchEvent(scrollContainer, "touchend", 1);
+
+ // After touch ends, normal scroll tracking should resume.
+ // Scroll to the very bottom and verify the button disappears.
+ scrollContainer.scrollTop =
+ scrollContainer.scrollHeight - scrollContainer.clientHeight;
+ scrollContainer.dispatchEvent(new Event("scroll"));
+
+ await waitFor(() => {
+ expect(
+ canvas.queryByRole("button", { name: "Scroll to bottom" }),
+ ).toBeNull();
+ });
+ },
+};
+
+const wheelGuardScrollStore = buildStoreWithMessages(buildLongConversation(30));
+
+/** During active wheel/trackpad scrolling, the container ResizeObserver
+ * must not snap scroll to bottom. This prevents desktop scroll jump. */
+export const ScrollNotJumpedDuringWheel: Story = {
+ decorators: scrollStoryDecorators,
+ render: () => ,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const scrollContainer = canvas.getByTestId("scroll-container");
+
+ await waitForScrollOverflow(scrollContainer);
+
+ // Wait for the initial bottom pin to settle.
+ await waitFor(
+ () => {
+ const dist =
+ scrollContainer.scrollHeight -
+ scrollContainer.scrollTop -
+ scrollContainer.clientHeight;
+ expect(dist).toBeLessThan(5);
+ },
+ { timeout: 2000 },
+ );
+ await new Promise((resolve) =>
+ requestAnimationFrame(() => resolve()),
+ );
+
+ // Simulate a wheel event (trackpad/mouse scroll).
+ scrollContainer.dispatchEvent(
+ new WheelEvent("wheel", { bubbles: true, deltaY: -50 }),
+ );
+
+ // Scroll partway up, within the 100px threshold but not at
+ // the absolute bottom. This simulates the user scrolling up
+ // slightly with a trackpad.
+ const offsetFromBottom = 25;
+ const targetScrollTop =
+ scrollContainer.scrollHeight -
+ scrollContainer.clientHeight -
+ offsetFromBottom;
+ scrollContainer.scrollTop = targetScrollTop;
+ scrollContainer.dispatchEvent(new Event("scroll"));
+
+ // Record the scroll position before new content arrives.
+ const scrollTopBeforeAppend = scrollContainer.scrollTop;
+ const scrollHeightBeforeAppend = scrollContainer.scrollHeight;
+
+ // Simulate new assistant content arriving while the wheel guard is
+ // active. Keep the append small enough to remain within the
+ // near-bottom threshold so auto-follow should resume.
+ const existing = getStoreMessages(wheelGuardScrollStore);
+ wheelGuardScrollStore.replaceMessages(
+ existing.concat([buildMessage(31, "assistant", "Short update.")]),
+ );
+
+ await waitFor(() => {
+ expect(scrollContainer.scrollHeight).toBeGreaterThan(
+ scrollHeightBeforeAppend,
+ );
+ });
+
+ // During active wheel scrolling, the deferred pin should stay
+ // suppressed until the debounce expires.
+ expect(scrollContainer.scrollTop).toBeLessThanOrEqual(
+ scrollTopBeforeAppend + 1,
+ );
+
+ // Wait for the wheel debounce to clear (150ms) plus a
+ // buffer, then verify the missed bottom pin is applied.
+ await new Promise((resolve) => setTimeout(resolve, 200));
+
+ await waitFor(() => {
+ const dist =
+ scrollContainer.scrollHeight -
+ scrollContainer.scrollTop -
+ scrollContainer.clientHeight;
+ expect(dist).toBeLessThan(5);
+ expect(
+ canvas.queryByRole("button", { name: "Scroll to bottom" }),
+ ).toBeNull();
+ });
+ },
+};
+
+const wheelDeferredStore = buildStoreWithMessages(buildLongConversation(30));
+
+/**
+ * Regression: when content grows during a wheel burst (so
+ * ResizeObserver pins are deferred), the transcript must recover
+ * auto-follow after the wheel debounce expires instead of getting
+ * stuck in a jumped-up position.
+ */
+export const ScrollRepinnedAfterWheelDeferredAppend: Story = {
+ decorators: scrollStoryDecorators,
+ render: () => ,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const scrollContainer = canvas.getByTestId("scroll-container");
+
+ await waitForScrollOverflow(scrollContainer);
+
+ // Wait for the initial bottom pin to settle.
+ await waitFor(
+ () => {
+ const dist =
+ scrollContainer.scrollHeight -
+ scrollContainer.scrollTop -
+ scrollContainer.clientHeight;
+ expect(dist).toBeLessThan(5);
+ },
+ { timeout: 2000 },
+ );
+ await new Promise((resolve) =>
+ requestAnimationFrame(() => resolve()),
+ );
+
+ // Start a wheel burst to activate the wheel guard.
+ scrollContainer.dispatchEvent(
+ new WheelEvent("wheel", { bubbles: true, deltaY: 3 }),
+ );
+
+ // Append content while the wheel guard is active. This
+ // defers the ResizeObserver pin (pendingWheelPinRef = true)
+ // and creates the gap that previously triggered the bug.
+ const existing = getStoreMessages(wheelDeferredStore);
+ wheelDeferredStore.replaceMessages(
+ existing.concat([
+ buildMessage(31, "assistant", "A ".repeat(200)),
+ buildMessage(32, "assistant", "B ".repeat(200)),
+ ]),
+ );
+
+ // Fire a second wheel tick. In the old code, this wheel
+ // event called handleUserInterrupt() which saw the gap
+ // and falsely disabled auto-follow.
+ scrollContainer.dispatchEvent(
+ new WheelEvent("wheel", { bubbles: true, deltaY: 3 }),
+ );
+
+ // Wait for the 150ms wheel debounce to expire, plus the
+ // catch-up scrollTranscriptToBottom to settle.
+ await waitFor(
+ () => {
+ const dist =
+ scrollContainer.scrollHeight -
+ scrollContainer.scrollTop -
+ scrollContainer.clientHeight;
+ expect(dist).toBeLessThan(5);
+ },
+ { timeout: 2000 },
+ );
+
+ // Scroll-to-bottom button should not be visible.
+ expect(
+ canvas.queryByRole("button", { name: "Scroll to bottom" }),
+ ).toBeNull();
+ },
+};
diff --git a/site/src/pages/AgentsPage/components/AgentDetailView.tsx b/site/src/pages/AgentsPage/components/AgentDetailView.tsx
index 3250416f45..018a85f4b1 100644
--- a/site/src/pages/AgentsPage/components/AgentDetailView.tsx
+++ b/site/src/pages/AgentsPage/components/AgentDetailView.tsx
@@ -623,6 +623,28 @@ const ScrollAnchoredContainer: FC<{
// scroll reaches its destination or the user actively interrupts.
const isRestoringScrollRef = useRef(false);
const cancelPendingPinsRef = useRef<(() => void) | null>(null);
+ // Guard counter: positive while one or more touch contacts are active.
+ // Prevents ResizeObserver callbacks from snapping scroll to bottom
+ // during mobile URL bar show/hide, which triggers container resize
+ // events while the user's finger is still on the screen.
+ const activeTouchCountRef = useRef(0);
+ // Guard flag: true while the user is actively scrolling via
+ // mouse wheel or trackpad. Set on each wheel event and cleared
+ // after a short debounce period. Prevents ResizeObserver
+ // callbacks from snapping scroll to bottom during active
+ // wheel/trackpad scrolling within the near-bottom threshold.
+ const isWheelScrollingRef = useRef(false);
+ // Track whether a resize would have pinned to bottom while the
+ // wheel guard was active. When scrolling stops, run one catch-up
+ // pin so auto-follow resumes without waiting for another resize.
+ const pendingWheelPinRef = useRef(false);
+ // Snapshot of autoScrollRef at the start of the current wheel
+ // burst. Used by the debounce timeout to decide whether to repin
+ // after deferred content growth.
+ const wheelSessionAutoScrollRef = useRef(false);
+ // scrollTop at the start of the current wheel burst. Compared
+ // against the final scrollTop to detect intentional upward scrolls.
+ const wheelSessionStartTopRef = useRef(0);
useLayoutEffect(() => {
isFetchingRef.current = isFetchingMoreMessages;
if (isFetchingMoreMessages) {
@@ -767,6 +789,7 @@ const ScrollAnchoredContainer: FC<{
const scheduleBottomPin = () => {
cancelPendingPins();
+ pendingWheelPinRef.current = false;
isRestoringScrollRef.current = true;
// Double-RAF lets React's commit phase and the browser's
// layout pass both complete before we pin to bottom.
@@ -840,7 +863,11 @@ const ScrollAnchoredContainer: FC<{
return;
}
- if (autoScrollRef.current) {
+ if (autoScrollRef.current && activeTouchCountRef.current === 0) {
+ if (isWheelScrollingRef.current) {
+ pendingWheelPinRef.current = true;
+ return;
+ }
scheduleBottomPin();
return;
}
@@ -891,6 +918,13 @@ const ScrollAnchoredContainer: FC<{
if (Math.abs(delta) < 1 || !autoScrollRef.current) {
return;
}
+ if (activeTouchCountRef.current > 0) {
+ return;
+ }
+ if (isWheelScrollingRef.current) {
+ pendingWheelPinRef.current = true;
+ return;
+ }
if (restoreGuardRafId !== null) {
cancelAnimationFrame(restoreGuardRafId);
@@ -922,6 +956,7 @@ const ScrollAnchoredContainer: FC<{
if (!container) return;
let rafId: number | null = null;
+ let wheelTimeoutId: ReturnType | null = null;
const handleScroll = () => {
// While a programmatic scroll is in progress (e.g. smooth
@@ -962,24 +997,136 @@ const ScrollAnchoredContainer: FC<{
// not break streaming follow-mode.
if (!isNearBottom(container)) {
autoScrollRef.current = false;
+ pendingWheelPinRef.current = false;
cancelPendingPinsRef.current?.();
}
};
+ const getChangedTouchCount = (event: TouchEvent) => {
+ // A single touch event can add or remove multiple contacts.
+ // Count changed touches so the guard stays active until the
+ // final finger leaves the screen.
+ return Math.max(event.changedTouches.length, 1);
+ };
+
+ const handleWheel = () => {
+ if (!isWheelScrollingRef.current) {
+ // First wheel event of this burst: snapshot the current
+ // follow state and scroll position so the debounce
+ // timeout can distinguish content-driven gaps from
+ // intentional user scroll.
+ wheelSessionAutoScrollRef.current = autoScrollRef.current;
+ wheelSessionStartTopRef.current = container.scrollTop;
+ }
+ isWheelScrollingRef.current = true;
+ if (wheelTimeoutId !== null) {
+ clearTimeout(wheelTimeoutId);
+ }
+ // Clear the wheel-scrolling flag after 150ms of inactivity.
+ // This covers trackpad momentum and rapid discrete wheel
+ // ticks without permanently blocking ResizeObserver pins.
+ wheelTimeoutId = setTimeout(() => {
+ isWheelScrollingRef.current = false;
+ wheelTimeoutId = null;
+ const wasPinDeferred = pendingWheelPinRef.current;
+ pendingWheelPinRef.current = false;
+ // Repin if the wheel burst started in follow mode and
+ // content grew while the guard was active, unless the
+ // user clearly scrolled away from the near-bottom follow
+ // zone during the burst.
+ if (wasPinDeferred && wheelSessionAutoScrollRef.current) {
+ const scrolledUp =
+ container.scrollTop < wheelSessionStartTopRef.current - 1;
+ const nearBottom = isNearBottom(container);
+ if (!scrolledUp || nearBottom) {
+ scrollTranscriptToBottom({
+ behavior: "instant",
+ scrollContainerRef,
+ autoScrollRef,
+ isRestoringScrollRef,
+ setShowScrollToBottom,
+ });
+ } else {
+ autoScrollRef.current = false;
+ setShowScrollToBottom(true);
+ }
+ } else {
+ // Sync follow state with the actual scroll position
+ // now that the wheel burst is over.
+ const nearBottom = isNearBottom(container);
+ autoScrollRef.current = nearBottom;
+ setShowScrollToBottom(!nearBottom);
+ }
+ }, 150);
+ // Clear the restoration guard so user input can interrupt
+ // programmatic scrolls, but do not call handleUserInterrupt()
+ // here. The scroll handler and debounce timeout manage
+ // follow-mode transitions to avoid races with deferred
+ // content growth.
+ isRestoringScrollRef.current = false;
+ cancelPendingPinsRef.current?.();
+ };
+
+ const handleTouchStart = (event: TouchEvent) => {
+ activeTouchCountRef.current += getChangedTouchCount(event);
+ handleUserInterrupt();
+ };
+
+ const handleTouchEnd = (event: TouchEvent) => {
+ activeTouchCountRef.current = Math.max(
+ 0,
+ activeTouchCountRef.current - getChangedTouchCount(event),
+ );
+ if (activeTouchCountRef.current === 0) {
+ // Re-evaluate because momentum scrolling may carry the
+ // user away from the bottom after touchstart initially
+ // saw them as near the bottom.
+ if (!isNearBottom(container)) {
+ autoScrollRef.current = false;
+ cancelPendingPinsRef.current?.();
+ }
+ }
+ };
+
container.addEventListener("scroll", handleScroll, { passive: true });
- container.addEventListener("wheel", handleUserInterrupt, {
+ container.addEventListener("wheel", handleWheel, {
passive: true,
});
- container.addEventListener("touchstart", handleUserInterrupt, {
+ container.addEventListener("touchstart", handleTouchStart, {
passive: true,
});
+ container.addEventListener("touchend", handleTouchEnd, {
+ passive: true,
+ });
+ // touchcancel fires when the OS interrupts a gesture (e.g.,
+ // incoming call, system gesture). Must also decrement the
+ // touch counter to avoid a stuck positive value.
+ container.addEventListener("touchcancel", handleTouchEnd, {
+ passive: true,
+ });
+ // Reset touch counter when page is hidden (e.g., tab switch).
+ // The browser may not fire touchend/touchcancel when the user
+ // switches away mid-gesture, which would leave the counter
+ // positive and permanently block ResizeObserver pins.
+ const handleVisibilityChange = () => {
+ if (document.hidden) {
+ activeTouchCountRef.current = 0;
+ }
+ };
+ document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
container.removeEventListener("scroll", handleScroll);
- container.removeEventListener("wheel", handleUserInterrupt);
- container.removeEventListener("touchstart", handleUserInterrupt);
+ container.removeEventListener("wheel", handleWheel);
+ container.removeEventListener("touchstart", handleTouchStart);
+ container.removeEventListener("touchend", handleTouchEnd);
+ container.removeEventListener("touchcancel", handleTouchEnd);
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
+ if (wheelTimeoutId !== null) {
+ clearTimeout(wheelTimeoutId);
+ }
};
}, [scrollContainerRef]);