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