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
+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(
() =>