From f25e68b8cc1c9a72e2c37deb37b51c18ee569527 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 3 Aug 2026 23:08:11 +0200 Subject: [PATCH] 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 --- .../e2e/browser/agent-scroll-return.spec.ts | 236 ++++++++++ .../src/agent-stream/strategy-web.test.tsx | 431 +++++++++++++++++- .../app/src/agent-stream/strategy-web.tsx | 314 +++++++++++-- .../agent-stream/use-scroll-to-message.web.ts | 11 +- packages/app/src/agent-stream/view.tsx | 29 +- .../app/src/components/retained-panel.tsx | 2 +- 6 files changed, 968 insertions(+), 55 deletions(-) create mode 100644 packages/app/e2e/browser/agent-scroll-return.spec.ts diff --git a/packages/app/e2e/browser/agent-scroll-return.spec.ts b/packages/app/e2e/browser/agent-scroll-return.spec.ts new file mode 100644 index 000000000..f274e564d --- /dev/null +++ b/packages/app/e2e/browser/agent-scroll-return.spec.ts @@ -0,0 +1,236 @@ +import { expect, type Page } from "@playwright/test"; +import { test } from "../support/fixtures"; +import { + readScrollMetrics, + scrollChatAwayFromBottom, +} from "../support/helpers/agent-bottom-anchor"; +import { getServerId } from "../support/helpers/server-id"; +import { seedMockAgentWorkspace, type MockAgentWorkspace } from "../support/helpers/mock-agent"; +import { + continueToNextQuestion, + fillQuestionAnswer, + submitQuestionAnswers, + waitForQuestionPrompt, +} from "../support/helpers/questions"; +import { openMobileAgentSidebar } from "../support/helpers/sidebar"; +import { + expectTimelinePromptVisible, + openAgentTimeline, + seedLongMockAgentTimeline, +} from "../support/helpers/timeline-pagination"; +import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui"; + +async function expectChatAtBottom(page: Page): Promise { + await expect + .poll(async () => (await readScrollMetrics(page)).distanceFromBottom, { + timeout: 10_000, + }) + .toBeLessThanOrEqual(72); +} + +async function openCompactWorkspace( + page: Page, + workspace: Pick, +): Promise { + await openMobileAgentSidebar(page); + await switchWorkspaceViaSidebar({ + page, + serverId: getServerId(), + workspaceId: workspace.workspaceId, + }); +} + +function armDelayedScrollForHiddenChat(page: Page): Promise { + return page + .locator('[data-testid="agent-chat-scroll"]:visible') + .first() + .evaluate((root) => { + const scroll = root as HTMLElement; + return new Promise((resolve) => { + const observer = new ResizeObserver(() => { + if (scroll.getClientRects().length > 0) return; + observer.disconnect(); + scroll.dispatchEvent(new Event("scroll")); + resolve(); + }); + observer.observe(scroll); + }); + }); +} + +test("pointer jitter cannot turn geometry changes into scroll intent", async ({ page }) => { + test.setTimeout(240_000); + const longChat = await seedLongMockAgentTimeline({ turns: 30 }); + const backgroundPrompt = "Continue after a pointer is released outside the chat"; + + try { + await page.setViewportSize({ width: 390, height: 844 }); + await openAgentTimeline(page, longChat); + await expectTimelinePromptVisible(page, longChat.newestPrompt); + await expectChatAtBottom(page); + + const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + const bounds = await scroll.boundingBox(); + expect(bounds, "Expected visible chat scroll bounds").not.toBeNull(); + await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2); + await page.mouse.down(); + await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2 - 3); + await page.setViewportSize({ width: 390, height: 1124 }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.mouse.move(410, bounds!.y + bounds!.height / 2 - 3); + await page.mouse.up(); + + await longChat.client.sendAgentMessage(longChat.agentId, backgroundPrompt); + await longChat.client.waitForFinish(longChat.agentId, 20_000); + await expectTimelinePromptVisible(page, backgroundPrompt); + await expectChatAtBottom(page); + } finally { + await longChat.cleanup(); + } +}); + +test("a retained chat ignores delayed scroll delivery while hidden", async ({ page }) => { + test.setTimeout(240_000); + const longChat = await seedLongMockAgentTimeline({ turns: 30 }); + const otherWorkspace = await seedMockAgentWorkspace({ + repoPrefix: "scroll-return-hidden-other-", + title: "Other workspace", + initialPrompt: "Open the other workspace", + }); + const backgroundPrompt = "emit 500 coalesced agent stream updates"; + + try { + await page.setViewportSize({ width: 390, height: 844 }); + await openAgentTimeline(page, longChat); + await expectTimelinePromptVisible(page, longChat.newestPrompt); + await expectChatAtBottom(page); + + const delayedScrollDelivered = armDelayedScrollForHiddenChat(page); + await openCompactWorkspace(page, otherWorkspace); + await delayedScrollDelivered; + await longChat.client.sendAgentMessage(longChat.agentId, backgroundPrompt); + await longChat.client.waitForFinish(longChat.agentId, 20_000); + + await openCompactWorkspace(page, longChat); + await expectTimelinePromptVisible(page, backgroundPrompt); + await expectChatAtBottom(page); + } finally { + await Promise.allSettled([longChat.cleanup(), otherWorkspace.cleanup()]); + } +}); + +test("a retained streaming chat returns to the bottom and continues following", async ({ + page, +}) => { + test.setTimeout(240_000); + const longChat = await seedLongMockAgentTimeline({ turns: 30 }); + const otherWorkspace = await seedMockAgentWorkspace({ + repoPrefix: "scroll-return-streaming-other-", + title: "Other workspace", + initialPrompt: "Open the other workspace", + }); + const streamingPrompt = "Continue streaming while this workspace is hidden"; + + try { + await page.setViewportSize({ width: 390, height: 844 }); + await openAgentTimeline(page, longChat); + await expectTimelinePromptVisible(page, longChat.newestPrompt); + await expectChatAtBottom(page); + + await longChat.client.sendAgentMessage(longChat.agentId, streamingPrompt); + await longChat.client.waitForAgentUpsert( + longChat.agentId, + (snapshot) => snapshot.status === "running", + 15_000, + ); + await openCompactWorkspace(page, otherWorkspace); + await openCompactWorkspace(page, longChat); + + await expectTimelinePromptVisible(page, streamingPrompt); + await expectChatAtBottom(page); + await longChat.client.waitForFinish(longChat.agentId, 20_000); + await expectChatAtBottom(page); + } finally { + await Promise.allSettled([longChat.cleanup(), otherWorkspace.cleanup()]); + } +}); + +test("a retained chat preserves an intentional reading position", async ({ page }) => { + test.setTimeout(240_000); + const longChat = await seedLongMockAgentTimeline({ turns: 30 }); + const otherWorkspace = await seedMockAgentWorkspace({ + repoPrefix: "scroll-return-reading-position-other-", + title: "Other workspace", + initialPrompt: "Open the other workspace", + }); + + try { + await page.setViewportSize({ width: 390, height: 844 }); + await openAgentTimeline(page, longChat); + await expectTimelinePromptVisible(page, longChat.newestPrompt); + const readingPosition = await scrollChatAwayFromBottom(page, { + deltaY: -900, + minDistanceFromBottom: 300, + }); + + await openCompactWorkspace(page, otherWorkspace); + await openCompactWorkspace(page, longChat); + + await expect + .poll(async () => Math.abs((await readScrollMetrics(page)).offsetY - readingPosition.offsetY)) + .toBeLessThanOrEqual(24); + } finally { + await Promise.allSettled([longChat.cleanup(), otherWorkspace.cleanup()]); + } +}); + +test("editing keys in an embedded question keep output following", async ({ page }) => { + test.setTimeout(240_000); + const longChat = await seedLongMockAgentTimeline({ turns: 30 }); + const questionPrompt = "Emit synthetic questions: two free-write questions."; + const firstQuestion = "What is the GitHub private repo URL to push to?"; + const secondQuestion = "What should the first commit message be?"; + + try { + await page.setViewportSize({ width: 390, height: 844 }); + await openAgentTimeline(page, longChat); + await expectTimelinePromptVisible(page, longChat.newestPrompt); + await expectChatAtBottom(page); + + await longChat.client.sendAgentMessage(longChat.agentId, questionPrompt); + await waitForQuestionPrompt(page, 30_000); + await fillQuestionAnswer(page, { + question: firstQuestion, + answer: "git@github.com:user/private-repo.git", + }); + const firstInput = page + .getByTestId("question-form-card") + .first() + .getByRole("textbox", { name: firstQuestion }); + const inputBounds = await firstInput.boundingBox(); + expect(inputBounds, "Expected embedded question input bounds").not.toBeNull(); + await page.mouse.move( + inputBounds!.x + inputBounds!.width / 2, + inputBounds!.y + inputBounds!.height / 2, + ); + await page.mouse.down(); + await page.mouse.move( + inputBounds!.x + inputBounds!.width / 2 + 20, + inputBounds!.y + inputBounds!.height / 2 - 3, + ); + await page.mouse.up(); + await firstInput.press("Home"); + await firstInput.press("ArrowUp"); + await continueToNextQuestion(page); + await fillQuestionAnswer(page, { + question: secondQuestion, + answer: "Initialize private repo", + }); + await submitQuestionAnswers(page); + await longChat.client.waitForFinish(longChat.agentId, 20_000); + + await expectChatAtBottom(page); + } finally { + await longChat.cleanup(); + } +}); diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index dd2f48271..61777cb21 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -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(); 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(); + 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(), + 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>(); + 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(); + 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) => ( + {strategy.render(input)} + ); + 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" }); + }); }); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 971d2bc5b..2c0864061 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -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, +): 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(null); const contentRef = useRef(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(null); const pendingAutoScrollFrameRef = useRef(null); const pendingAutoScrollTimeoutRef = useRef(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 = { diff --git a/packages/app/src/agent-stream/use-scroll-to-message.web.ts b/packages/app/src/agent-stream/use-scroll-to-message.web.ts index aec7719ff..55aeae47a 100644 --- a/packages/app/src/agent-stream/use-scroll-to-message.web.ts +++ b/packages/app/src/agent-stream/use-scroll-to-message.web.ts @@ -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; rowVirtualizer: Virtualizer; 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, diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index a23c60657..35a8afef5 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -269,6 +269,14 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [ ]; const EMPTY_STREAM_HEAD: StreamItem[] = []; + +function useRetainedValue(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