Preserve chat scroll position across workspace switches (#2838)

* fix(app): preserve chat scroll across workspace switches

Hidden retained panels can emit geometry-driven scroll events while collapsed, which previously looked like user intent and disabled output following. Keep the retained DOM mounted, suspend its inactive scroll work, and only detach from the bottom after explicit input.

* fix(app): classify chat scroll intent at input

A held pointer is not evidence that a geometry-driven scroll was user initiated. Detach output following directly from upward wheel, key, touch, or mouse-drag input, and keep ordinary scroll events responsible only for metrics and bottom reattachment.

* fix(app): keep editing keys out of scroll intent

Question inputs render inside the chat viewport, so their caret-navigation keys bubble through the same keyboard boundary. Treat keys from editable descendants as editing intent while preserving viewport-level keyboard scrolling.

* fix(app): exclude zoom gestures from scroll intent

Ctrl-wheel and trackpad pinch events can carry a negative wheel delta without scrolling chat content. Keep those browser zoom gestures from disabling output following.

* fix(app): require pointer-driven chat movement

Mouse movement alone is not viewport scroll intent. Correlate actual upward scroll with short-lived content-drag evidence or a scrollbar-gutter gesture, while excluding embedded editors and retaining geometry-only following.

* fix(app): recognize overlay chat scrollbars

* fix(app): scope scroll intent to chat viewport

* fix(app): rearm chat history at scroll start

* fix(app): track chat autoscroll intent

* fix(app): separate pointer scroll gestures
This commit is contained in:
Mohamed Boudra
2026-08-03 23:08:11 +02:00
committed by GitHub
parent 777fe968f5
commit f25e68b8cc
6 changed files with 968 additions and 55 deletions
@@ -5,6 +5,7 @@ import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RetainedPanelActivity } from "@/components/retained-panel";
import type { StreamItem } from "@/types/stream";
import type { StreamRenderInput, StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
import { createWebStreamStrategy } from "./strategy-web";
@@ -25,6 +26,10 @@ vi.hoisted(() => {
});
});
vi.mock("react-native-unistyles", () => ({
withUnistyles: (Component: React.ComponentType) => Component,
}));
function userMessage(index: number): StreamItem {
return {
kind: "user_message",
@@ -533,6 +538,7 @@ describe("createWebStreamStrategy", () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const historyMounted = Array.from({ length: 20 }, (_, index) => userMessage(index));
const onNearHistoryStart = vi.fn().mockReturnValue(false);
const routeBottomAnchorRequest = {
agentId: "agent",
reason: "initial-entry" as const,
@@ -555,10 +561,10 @@ describe("createWebStreamStrategy", () => {
viewportRef,
routeBottomAnchorRequest,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
onNearHistoryStart,
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
hasOlderHistory: true,
olderHistoryProgressKey: "epoch-1:20",
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
@@ -595,7 +601,7 @@ describe("createWebStreamStrategy", () => {
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -240 }));
});
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 520 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
@@ -611,6 +617,14 @@ describe("createWebStreamStrategy", () => {
});
expect(scrollTo).not.toHaveBeenCalled();
await act(async () => Promise.resolve());
expect(onNearHistoryStart).toHaveBeenCalledOnce();
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -240 }));
});
await act(async () => Promise.resolve());
expect(onNearHistoryStart).toHaveBeenCalledTimes(2);
});
it("does not force bottom after upward wheel when cached scroll top is stale", async () => {
@@ -821,4 +835,413 @@ describe("createWebStreamStrategy", () => {
expect(scrollTo).toHaveBeenCalled();
});
it("keeps following output after geometry, nested-scroll, and zoom wheel events", () => {
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
const top = typeof options === "object" ? (options.top ?? 0) : 0;
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
});
HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const renderInput: StreamRenderInput = {
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [userMessage(1), userMessage(2)],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(strategy.render(renderInput)));
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "clientWidth", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
scrollContainer.getBoundingClientRect = () =>
({
bottom: 500,
height: 500,
left: 0,
right: 500,
top: 0,
width: 500,
x: 0,
y: 0,
toJSON: () => {},
}) as DOMRect;
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
const jitterPointerDown = new MouseEvent("pointerdown", {
bubbles: true,
button: 0,
buttons: 1,
clientX: 100,
clientY: 400,
});
const jitterPointerMove = new MouseEvent("pointermove", {
bubbles: true,
buttons: 1,
clientX: 100,
clientY: 397,
});
for (const event of [jitterPointerDown, jitterPointerMove]) {
Object.defineProperties(event, {
isPrimary: { value: true },
pointerId: { value: 1 },
pointerType: { value: "mouse" },
});
}
act(() => {
scrollContainer.dispatchEvent(jitterPointerDown);
window.dispatchEvent(jitterPointerMove);
});
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 700 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 800 });
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
scrollTo.mockClear();
const nestedScroller = document.createElement("div");
nestedScroller.style.overflowY = "auto";
Object.defineProperty(nestedScroller, "clientHeight", { configurable: true, value: 100 });
Object.defineProperty(nestedScroller, "scrollHeight", { configurable: true, value: 300 });
Object.defineProperty(nestedScroller, "scrollTop", { configurable: true, value: 100 });
scrollContainer.append(nestedScroller);
act(() => {
nestedScroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -100 }));
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 700 });
scrollContainer.dispatchEvent(new Event("scroll"));
scrollContainer.dispatchEvent(
new WheelEvent("wheel", { bubbles: true, ctrlKey: true, deltaY: -100 }),
);
});
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1800 });
act(() => {
root?.render(
strategy.render({
...renderInput,
segments: { ...renderInput.segments, liveHead: [userMessage(3)] },
boundary: { ...renderInput.boundary, hasLiveHead: true },
}),
);
});
expect(scrollTo).toHaveBeenCalledWith({ top: 1800, behavior: "auto" });
});
it("stops following output after scrollbar and middle-button upward scrolling", () => {
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
const top = typeof options === "object" ? (options.top ?? 0) : 0;
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
});
HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const renderInput: StreamRenderInput = {
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [userMessage(1), userMessage(2)],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef: React.createRef<StreamViewportHandle>(),
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(strategy.render(renderInput)));
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "clientWidth", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 520 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
scrollContainer.getBoundingClientRect = () =>
({
bottom: 500,
height: 500,
left: 0,
right: 520,
top: 0,
width: 520,
x: 0,
y: 0,
toJSON: () => {},
}) as DOMRect;
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
const scrollbarPointerDown = new MouseEvent("pointerdown", {
bubbles: true,
button: 0,
buttons: 1,
clientX: 515,
clientY: 400,
});
Object.defineProperties(scrollbarPointerDown, {
isPrimary: { value: true },
pointerId: { value: 1 },
pointerType: { value: "mouse" },
});
act(() => scrollContainer.dispatchEvent(scrollbarPointerDown));
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 700 });
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1800 });
act(() => {
root?.render(
strategy.render({
...renderInput,
segments: { ...renderInput.segments, liveHead: [userMessage(3)] },
boundary: { ...renderInput.boundary, hasLiveHead: true },
}),
);
});
expect(scrollTo).not.toHaveBeenCalled();
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1300 });
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 500 });
scrollContainer.getBoundingClientRect = () =>
({
bottom: 500,
height: 500,
left: 0,
right: 500,
top: 0,
width: 500,
x: 0,
y: 0,
toJSON: () => {},
}) as DOMRect;
const overlayScrollbarPointerDown = new MouseEvent("pointerdown", {
bubbles: true,
button: 0,
buttons: 1,
clientX: 495,
clientY: 400,
});
Object.defineProperties(overlayScrollbarPointerDown, {
isPrimary: { value: true },
pointerId: { value: 2 },
pointerType: { value: "mouse" },
});
act(() => scrollContainer.dispatchEvent(overlayScrollbarPointerDown));
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 900 });
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1900 });
act(() => {
root?.render(
strategy.render({
...renderInput,
segments: {
...renderInput.segments,
liveHead: [userMessage(3), userMessage(4)],
},
boundary: { ...renderInput.boundary, hasLiveHead: true },
}),
);
});
expect(scrollTo).not.toHaveBeenCalled();
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1400 });
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
const autoscrollPointerDown = new MouseEvent("pointerdown", {
bubbles: true,
button: 1,
buttons: 4,
clientX: 100,
clientY: 400,
});
const autoscrollPointerUp = new MouseEvent("pointerup", {
bubbles: true,
button: 1,
buttons: 0,
clientX: 100,
clientY: 400,
});
const autoscrollPointerMove = new MouseEvent("pointermove", {
bubbles: true,
buttons: 0,
clientX: 100,
clientY: 300,
});
for (const event of [autoscrollPointerDown, autoscrollPointerUp, autoscrollPointerMove]) {
Object.defineProperties(event, {
isPrimary: { value: true },
pointerId: { value: 3 },
pointerType: { value: "mouse" },
});
}
act(() => {
scrollContainer.dispatchEvent(autoscrollPointerDown);
window.dispatchEvent(autoscrollPointerUp);
window.dispatchEvent(autoscrollPointerMove);
});
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2000 });
act(() => {
root?.render(
strategy.render({
...renderInput,
segments: {
...renderInput.segments,
liveHead: [userMessage(3), userMessage(4), userMessage(5)],
},
boundary: { ...renderInput.boundary, hasLiveHead: true },
}),
);
});
expect(scrollTo).not.toHaveBeenCalled();
});
it("keeps the retained viewport mounted while inactive and reconciles it once on return", () => {
const observed = new Map<Element, ReturnType<typeof vi.fn>>();
Object.defineProperty(globalThis, "ResizeObserver", {
configurable: true,
value: class ResizeObserver {
readonly disconnect = vi.fn();
observe(target: Element) {
observed.set(target, this.disconnect);
}
},
});
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
const top = typeof options === "object" ? (options.top ?? 0) : 0;
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
});
HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const renderInput: StreamRenderInput = {
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [userMessage(1), userMessage(2)],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
};
const renderWithActivity = (active: boolean, input: StreamRenderInput = renderInput) => (
<RetainedPanelActivity active={active}>{strategy.render(input)}</RetainedPanelActivity>
);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(renderWithActivity(true)));
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
const viewportObserverDisconnect = observed.get(scrollContainer);
expect(viewportObserverDisconnect).toBeDefined();
act(() => root?.render(renderWithActivity(false)));
expect(container.querySelector('[data-testid="agent-chat-scroll"]')).toBe(scrollContainer);
expect(viewportObserverDisconnect).toHaveBeenCalledOnce();
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2200 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
scrollContainer.dispatchEvent(new Event("scroll"));
root?.render(
renderWithActivity(false, {
...renderInput,
segments: { ...renderInput.segments, liveHead: [userMessage(3)] },
boundary: { ...renderInput.boundary, hasLiveHead: true },
}),
);
});
expect(scrollTo).not.toHaveBeenCalled();
act(() => {
root?.render(
renderWithActivity(true, {
...renderInput,
segments: { ...renderInput.segments, liveHead: [userMessage(3)] },
boundary: { ...renderInput.boundary, hasLiveHead: true },
}),
);
});
expect(container.querySelector('[data-testid="agent-chat-scroll"]')).toBe(scrollContainer);
expect(scrollTo).toHaveBeenCalledWith({ top: 2200, behavior: "auto" });
});
});
+278 -36
View File
@@ -9,9 +9,11 @@ import React, {
} from "react";
import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual";
import { withUnistyles } from "react-native-unistyles";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useStableEvent } from "@/hooks/use-stable-event";
import type { Theme } from "@/styles/theme";
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
import { estimateStreamItemHeight } from "./web-virtualization";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import { createStreamStrategy } from "./strategy";
@@ -51,6 +53,7 @@ const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
const HISTORY_START_SETTLE_FRAMES = 2;
const HISTORY_START_SLOT_HEIGHT_PX = 32;
const CONTENT_PADDING_TOP_PX = 16;
const UPWARD_INPUT_EVIDENCE_TIMEOUT_MS = 100;
const VIRTUALIZER_SCROLL_MARGIN_PX = HISTORY_START_SLOT_HEIGHT_PX + CONTENT_PADDING_TOP_PX;
// A row has to clear this much of the viewport top before the next one takes over as the
// reading position, so a row resting exactly on the edge does not flip back and forth.
@@ -105,6 +108,77 @@ function isScrollContainerAtBottom(
return isScrollContainerNearBottom(scrollContainer, AUTO_SCROLL_RESUME_THRESHOLD_PX);
}
function isScrollContainerMeasurable(
scrollContainer: Pick<HTMLElement, "clientHeight" | "scrollHeight">,
): boolean {
return scrollContainer.clientHeight > 0 && scrollContainer.scrollHeight > 0;
}
function isEditableEventTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) {
return false;
}
const editableRoot = target.closest("input, textarea, [contenteditable]");
if (!editableRoot) {
return false;
}
const tagName = editableRoot.tagName.toLowerCase();
if (tagName === "input" || tagName === "textarea") {
return true;
}
return editableRoot.getAttribute("contenteditable")?.toLowerCase() !== "false";
}
function isUpwardViewportScrollKey(event: KeyboardEvent): boolean {
if (isEditableEventTarget(event.target)) {
return false;
}
return (
event.key === "ArrowUp" ||
event.key === "PageUp" ||
event.key === "Home" ||
(event.key === " " && event.shiftKey)
);
}
function canNestedScrollerConsumeUpwardInput(
target: EventTarget | null,
scrollContainer: HTMLElement,
): boolean {
let element = target instanceof Element ? target : null;
while (element && element !== scrollContainer) {
if (element instanceof HTMLElement) {
const overflowY = window.getComputedStyle(element).overflowY;
const canScroll = overflowY === "auto" || overflowY === "scroll";
if (canScroll && element.scrollHeight > element.clientHeight && element.scrollTop > 0) {
return true;
}
}
element = element.parentElement;
}
return false;
}
function isVerticalScrollbarGutterPress(
event: PointerEvent,
scrollContainer: HTMLElement,
): boolean {
if (event.target !== scrollContainer) {
return false;
}
if (scrollContainer.scrollHeight <= scrollContainer.clientHeight) {
return false;
}
const scrollbarWidth = Math.max(
scrollContainer.offsetWidth - scrollContainer.clientWidth,
WEB_SCROLLBAR_SIZE_PX,
);
const bounds = scrollContainer.getBoundingClientRect();
return window.getComputedStyle(scrollContainer).direction === "rtl"
? event.clientX <= bounds.left + scrollbarWidth
: event.clientX >= bounds.right - scrollbarWidth;
}
function scrollElementToBottom(
scrollContainer: HTMLElement,
behavior: ScrollBehaviorLike = "auto",
@@ -159,6 +233,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollEnabled,
isMobileBreakpoint,
} = props;
const isActive = useRetainedPanelActive();
const isActiveRef = useRef(isActive);
const scrollContainerRef = useRef<HTMLElement | null>(null);
const contentRef = useRef<HTMLElement | null>(null);
const handleScrollContainerRef = useCallback((node: HTMLElement | null) => {
@@ -175,7 +251,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return value;
};
const lastKnownScrollTopRef = useRef(0);
const pendingUserScrollUpIntentRef = useRef(false);
const mouseScrollGestureRef = useRef<
| { kind: "scrollbar"; pointerId: number }
| {
kind: "autoscroll";
pointerId: number;
lastClientY: number;
hasUpwardEvidence: boolean;
evidenceExpiryFrame: number | null;
}
| null
>(null);
const upwardInputEvidenceUntilRef = useRef(0);
const lastTouchClientYRef = useRef<number | null>(null);
const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
@@ -196,6 +283,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
renderLiveAuxiliary,
} = renderers;
isActiveRef.current = isActive;
followOutputRef.current = followOutput;
const hasRouteBottomAnchorRequest = routeBottomAnchorRequest !== null;
@@ -234,7 +322,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const virtualTotalSize = rowVirtualizer.getTotalSize();
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput | null => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
if (!isActiveRef.current || !scrollContainer || !isScrollContainerMeasurable(scrollContainer)) {
return null;
}
const bottomAnchorSettled =
@@ -371,6 +459,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
});
useLayoutEffect(() => {
if (!isActiveRef.current) {
return;
}
const anchor = historyStartPrependAnchorRef.current;
if (!anchor || anchor.progressKey === olderHistoryProgressKey) {
return;
@@ -402,7 +493,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
const frame = window.requestAnimationFrame(() => {
pendingFrames.delete(node);
if (node.isConnected) {
if (isActiveRef.current && node.isConnected) {
rowVirtualizer.measureElement(node);
}
});
@@ -434,10 +525,47 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
}, []);
const clearMouseScrollGesture = useCallback(() => {
const gesture = mouseScrollGestureRef.current;
const evidenceExpiryFrame = gesture?.kind === "autoscroll" ? gesture.evidenceExpiryFrame : null;
if (evidenceExpiryFrame !== null && evidenceExpiryFrame !== undefined) {
window.cancelAnimationFrame(evidenceExpiryFrame);
}
mouseScrollGestureRef.current = null;
}, []);
const clearUpwardInputEvidence = useCallback(() => {
upwardInputEvidenceUntilRef.current = 0;
}, []);
const markUpwardInputEvidence = useCallback(() => {
upwardInputEvidenceUntilRef.current =
window.performance.now() + UPWARD_INPUT_EVIDENCE_TIMEOUT_MS;
}, []);
useLayoutEffect(() => {
if (isActive) {
return;
}
cancelPendingStickToBottom();
historyStartSettleSchedulerRef.current?.cancel();
for (const frame of pendingVirtualRowMeasureFramesRef.current.values()) {
window.cancelAnimationFrame(frame);
}
pendingVirtualRowMeasureFramesRef.current.clear();
clearMouseScrollGesture();
clearUpwardInputEvidence();
lastTouchClientYRef.current = null;
}, [cancelPendingStickToBottom, clearMouseScrollGesture, clearUpwardInputEvidence, isActive]);
const scrollMessagesToBottom = useCallback(
(behavior: ScrollBehaviorLike = "auto") => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
if (
!isActiveRef.current ||
!scrollContainer ||
!isScrollContainerMeasurable(scrollContainer)
) {
return;
}
if (isScrollContainerOverscrolledPastBottom(scrollContainer)) {
@@ -452,6 +580,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
);
const scheduleStickToBottom = useCallback(() => {
if (!isActiveRef.current) {
return;
}
const scrollContainer = scrollContainerRef.current;
if (scrollContainer && isScrollContainerOverscrolledPastBottom(scrollContainer)) {
return;
@@ -461,7 +592,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
pendingAutoScrollFrameRef.current = null;
if (!followOutputRef.current) {
if (!isActiveRef.current || !followOutputRef.current) {
return;
}
scrollMessagesToBottom("auto");
@@ -477,6 +608,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
// Rows are laid out in DOM order, virtualized block first, so the first row whose bottom
// clears the reading line is the one the reader is looking at.
const reportReadingPosition = useStableEvent(() => {
if (!isActiveRef.current) {
return;
}
if (!onReadingPositionChange) {
return;
}
@@ -502,6 +636,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
});
const updateScrollMetrics = useCallback(() => {
if (!isActiveRef.current) {
return;
}
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
onNearBottomChange(true);
@@ -512,6 +649,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [onNearBottomChange, reportReadingPosition]);
const { isJumpSettling, scrollToMessage } = useScrollToMessage({
active: isActive,
scrollContainerRef,
rowVirtualizer,
historyVirtualized: segments.historyVirtualized,
@@ -520,9 +658,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
onNearBottomChange,
});
const stopFollowingOutputFromUserIntent = useStableEvent(() => {
cancelPendingStickToBottom();
if (followOutputRef.current) {
setFollowOutput(false);
}
rearmHistoryStartFromUserIntent();
});
const handleDomScroll = useCallback(() => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
if (!isActiveRef.current || !scrollContainer || !isScrollContainerMeasurable(scrollContainer)) {
return;
}
@@ -531,33 +677,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON;
const scrolledDown =
currentScrollTop > lastKnownScrollTopRef.current + USER_SCROLL_DELTA_EPSILON;
const mouseGesture = mouseScrollGestureRef.current;
const hasUpwardInputEvidence =
mouseGesture?.kind === "scrollbar" ||
(mouseGesture?.kind === "autoscroll" && mouseGesture.hasUpwardEvidence) ||
window.performance.now() < upwardInputEvidenceUntilRef.current;
if (scrolledUp) {
cancelPendingStickToBottom();
if (followOutputRef.current) {
setFollowOutput(false);
}
pendingUserScrollUpIntentRef.current = false;
rearmHistoryStartFromUserIntent();
} else if (!followOutputRef.current && isAtBottom && scrolledDown && !isJumpSettling()) {
if (!followOutputRef.current && isAtBottom && scrolledDown && !isJumpSettling()) {
setFollowOutput(true);
pendingUserScrollUpIntentRef.current = false;
} else if (followOutputRef.current && pendingUserScrollUpIntentRef.current) {
if (!isAtBottom) {
cancelPendingStickToBottom();
setFollowOutput(false);
}
pendingUserScrollUpIntentRef.current = false;
} else if (followOutputRef.current && scrolledUp && hasUpwardInputEvidence) {
stopFollowingOutputFromUserIntent();
}
lastKnownScrollTopRef.current = currentScrollTop;
updateScrollMetrics();
evaluateHistoryStart();
}, [
cancelPendingStickToBottom,
evaluateHistoryStart,
isJumpSettling,
rearmHistoryStartFromUserIntent,
stopFollowingOutputFromUserIntent,
updateScrollMetrics,
]);
@@ -580,7 +718,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [evaluateHistoryStart, props.agentId]);
useLayoutEffect(() => {
if (!isActivationReady) {
if (!isActiveRef.current || !isActivationReady) {
return;
}
if (hasRouteBottomAnchorRequest && !followOutputRef.current) {
@@ -615,13 +753,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
// Following output is a layout invariant: rows, footer, and bottom offset must
// reach the browser in the same paint.
useLayoutEffect(() => {
if (!followOutputRef.current) {
if (!isActive || !followOutputRef.current) {
return;
}
cancelPendingStickToBottom();
scrollMessagesToBottom("auto");
}, [
cancelPendingStickToBottom,
isActive,
renderLiveAuxiliary,
scrollMessagesToBottom,
segments.historyMounted,
@@ -631,6 +770,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
]);
useEffect(() => {
if (!isActive) {
return;
}
updateScrollMetrics();
evaluateHistoryStart();
if (historyStartPaginationStateRef.current.status === "settling") {
@@ -639,6 +781,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [
evaluateHistoryStart,
hasOlderHistory,
isActive,
isLoadingOlderHistory,
olderHistoryProgressKey,
scheduleHistoryStartPrependSettle,
@@ -649,7 +792,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
virtualTotalSize,
]);
useEffect(() => {
useLayoutEffect(() => {
if (!isActive) {
return;
}
const scrollContainer = scrollContainerRef.current;
const contentNode = contentRef.current;
if (!scrollContainer || typeof ResizeObserver === "undefined") {
@@ -682,24 +828,98 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [
applyHistoryStartPrependAnchor,
evaluateHistoryStart,
isActive,
scheduleHistoryStartPrependSettle,
scheduleStickToBottom,
updateScrollMetrics,
]);
useEffect(() => {
useLayoutEffect(() => {
if (!isActive) {
return;
}
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
return;
}
const handleWheel = (event: WheelEvent) => {
if (event.deltaY < 0) {
pendingUserScrollUpIntentRef.current = true;
cancelPendingStickToBottom();
const markUpwardViewportInput = () => {
markUpwardInputEvidence();
if (scrollContainer.scrollTop <= USER_SCROLL_DELTA_EPSILON) {
rearmHistoryStartFromUserIntent();
}
};
const handleWheel = (event: WheelEvent) => {
if (
!event.ctrlKey &&
event.deltaY < 0 &&
!canNestedScrollerConsumeUpwardInput(event.target, scrollContainer)
) {
markUpwardViewportInput();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!isUpwardViewportScrollKey(event)) {
return;
}
if (canNestedScrollerConsumeUpwardInput(event.target, scrollContainer)) {
return;
}
markUpwardViewportInput();
};
const handlePointerDown = (event: PointerEvent) => {
clearMouseScrollGesture();
if (
event.pointerType !== "mouse" ||
!event.isPrimary ||
(event.button !== 0 && event.button !== 1) ||
isEditableEventTarget(event.target)
) {
return;
}
if (event.button === 0) {
if (isVerticalScrollbarGutterPress(event, scrollContainer)) {
mouseScrollGestureRef.current = { kind: "scrollbar", pointerId: event.pointerId };
}
return;
}
mouseScrollGestureRef.current = {
kind: "autoscroll",
pointerId: event.pointerId,
lastClientY: event.clientY,
hasUpwardEvidence: false,
evidenceExpiryFrame: null,
};
};
const handlePointerMove = (event: PointerEvent) => {
const gesture = mouseScrollGestureRef.current;
if (!gesture || gesture.kind !== "autoscroll" || gesture.pointerId !== event.pointerId) {
return;
}
if (event.clientY < gesture.lastClientY - USER_SCROLL_DELTA_EPSILON) {
gesture.hasUpwardEvidence = true;
if (gesture.evidenceExpiryFrame !== null) {
window.cancelAnimationFrame(gesture.evidenceExpiryFrame);
}
gesture.evidenceExpiryFrame = window.requestAnimationFrame(() => {
if (mouseScrollGestureRef.current === gesture) {
mouseScrollGestureRef.current = null;
}
});
}
gesture.lastClientY = event.clientY;
};
const handlePointerEnd = (event: PointerEvent) => {
const gesture = mouseScrollGestureRef.current;
if (gesture?.pointerId === event.pointerId && gesture.kind === "scrollbar") {
clearMouseScrollGesture();
}
};
const handlePointerCancel = (event: PointerEvent) => {
if (mouseScrollGestureRef.current?.pointerId === event.pointerId) {
clearMouseScrollGesture();
}
};
const handleTouchStart = (event: TouchEvent) => {
const touch = event.touches[0];
if (!touch) {
@@ -713,10 +933,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return;
}
const previousTouchY = lastTouchClientYRef.current;
if (previousTouchY !== null && touch.clientY > previousTouchY + 1) {
pendingUserScrollUpIntentRef.current = true;
cancelPendingStickToBottom();
rearmHistoryStartFromUserIntent();
if (
previousTouchY !== null &&
touch.clientY > previousTouchY + 1 &&
!canNestedScrollerConsumeUpwardInput(event.target, scrollContainer)
) {
markUpwardViewportInput();
}
lastTouchClientYRef.current = touch.clientY;
};
@@ -726,6 +948,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollContainer.addEventListener("scroll", handleDomScroll, { passive: true });
scrollContainer.addEventListener("wheel", handleWheel, { passive: true });
scrollContainer.addEventListener("keydown", handleKeyDown, { passive: true });
scrollContainer.addEventListener("pointerdown", handlePointerDown, { passive: true });
window.addEventListener("pointermove", handlePointerMove, { passive: true });
window.addEventListener("pointerup", handlePointerEnd, { passive: true });
window.addEventListener("pointercancel", handlePointerCancel, { passive: true });
window.addEventListener("blur", clearMouseScrollGesture);
scrollContainer.addEventListener("touchstart", handleTouchStart, { passive: true });
scrollContainer.addEventListener("touchmove", handleTouchMove, { passive: true });
scrollContainer.addEventListener("touchend", handleTouchEnd, { passive: true });
@@ -734,12 +962,26 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return () => {
scrollContainer.removeEventListener("scroll", handleDomScroll);
scrollContainer.removeEventListener("wheel", handleWheel);
scrollContainer.removeEventListener("keydown", handleKeyDown);
scrollContainer.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerEnd);
window.removeEventListener("pointercancel", handlePointerCancel);
window.removeEventListener("blur", clearMouseScrollGesture);
scrollContainer.removeEventListener("touchstart", handleTouchStart);
scrollContainer.removeEventListener("touchmove", handleTouchMove);
scrollContainer.removeEventListener("touchend", handleTouchEnd);
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
clearUpwardInputEvidence();
};
}, [cancelPendingStickToBottom, handleDomScroll, rearmHistoryStartFromUserIntent]);
}, [
clearMouseScrollGesture,
clearUpwardInputEvidence,
handleDomScroll,
isActive,
markUpwardInputEvidence,
rearmHistoryStartFromUserIntent,
]);
useEffect(() => {
const handle: StreamViewportHandle = {
@@ -1,9 +1,10 @@
import type React from "react";
import { useCallback, useEffect, useMemo } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo } from "react";
import type { Virtualizer } from "@tanstack/react-virtual";
import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle";
interface UseScrollToMessageInput {
active: boolean;
scrollContainerRef: React.RefObject<HTMLElement | null>;
rowVirtualizer: Virtualizer<HTMLElement, Element>;
historyVirtualized: readonly { id: string }[];
@@ -23,6 +24,7 @@ const SCROLL_AFFECTING_KEYS = new Set([
]);
export function useScrollToMessage({
active,
scrollContainerRef,
rowVirtualizer,
historyVirtualized,
@@ -79,9 +81,15 @@ export function useScrollToMessage({
);
useEffect(() => () => settleController.cancel(), [settleController]);
useLayoutEffect(() => {
if (!active) {
settleController.cancel();
}
}, [active, settleController]);
const scrollToMessage = useCallback(
(itemId: string) => {
if (!active) return;
const container = scrollContainerRef.current;
if (!container) return;
cancelPendingStickToBottom();
@@ -109,6 +117,7 @@ export function useScrollToMessage({
}
},
[
active,
cancelPendingStickToBottom,
historyVirtualized,
onNearBottomChange,
+16 -13
View File
@@ -269,6 +269,14 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
];
const EMPTY_STREAM_HEAD: StreamItem[] = [];
function useRetainedValue<T>(value: T, active: boolean): T {
const retainedRef = useRef(value);
if (active) {
retainedRef.current = value;
}
return active ? value : retainedRef.current;
}
const EMPTY_PENDING_MESSAGE_SUBMISSIONS: readonly PendingMessageSubmission[] = [];
const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200;
@@ -465,20 +473,15 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
});
});
// Freeze stream data while this tab slot is hidden to prevent offscreen FlatList
// cell-window renders on every 48ms flush from background agents.
// Freeze stream presentation while this tab slot is hidden to prevent offscreen
// cell-window and turn-lifecycle renders from background agents.
// When isActive flips back to true, the context change triggers a re-render and
// the component reads the current (fresh) streamItems/streamHead from props.
const isActive = useRetainedPanelActive();
const frozenStreamItemsRef = useRef(streamItems);
const frozenStreamHeadRef = useRef(streamHead);
if (isActive) {
frozenStreamItemsRef.current = streamItems;
frozenStreamHeadRef.current = streamHead;
}
const effectiveStreamItems = isActive ? streamItems : frozenStreamItemsRef.current;
const effectiveStreamHead = isActive ? streamHead : frozenStreamHeadRef.current;
const isTurnActive = turnPresentation.isActive;
const effectiveStreamItems = useRetainedValue(streamItems, isActive);
const effectiveStreamHead = useRetainedValue(streamHead, isActive);
const effectiveTurnPresentation = useRetainedValue(turnPresentation, isActive);
const isTurnActive = effectiveTurnPresentation.isActive;
// Keep retained history outside the 48ms live-head flush path.
const preparedToolCallHistory = useMemo(
() => prepareToolCallHistory(toolCallDetailLevel, effectiveStreamItems),
@@ -505,7 +508,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const baseRenderModel = useMemo(() => {
return buildAgentStreamRenderModel({
isTurnActive,
activeTurnStartedAt: turnPresentation.startedAt,
activeTurnStartedAt: effectiveTurnPresentation.startedAt,
tail: projectedToolCalls.tail,
head: projectedToolCalls.head,
platform: isWeb ? "web" : "native",
@@ -516,7 +519,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
isTurnActive,
projectedToolCalls.head,
projectedToolCalls.tail,
turnPresentation.startedAt,
effectiveTurnPresentation.startedAt,
]);
const streamLayout = useMemo(
() =>
@@ -1,4 +1,4 @@
import { createContext, memo, type ReactNode, useContext } from "react";
import React, { createContext, memo, type ReactNode, useContext } from "react";
import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
const RetainedPanelActiveContext = createContext(true);