From 97536add75f8164f1320c3d3882fdbd5639f5240 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:53:01 +0800 Subject: [PATCH] fix(app): preserve bottom scroll anchoring (#44077) --- .../scroll-interaction.spec.ts | 54 +++++++++++++++++++ .../app/src/session/timeline/virtualizer.tsx | 38 +++++++++++-- 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts b/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts index f5bf2d417e1..7c85528bd5c 100644 --- a/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test" +import { createTwoFilesPatch } from "diff" import { defineVisualRegions, reportVisualStability, @@ -13,10 +14,63 @@ import { setupTimeline, shell, textPart, + toolPart, userMessage, type TimelineMessage, } from "./fixture" +test("follows an expanded patch that arrives as the user reaches the bottom", async ({ page }) => { + const toolID = "prt_bottom_follow_patch" + const input = { patchText: "Update src/edit.ts" } + const timeline = await setupTimeline(page, { + messages: [ + ...history(20), + userMessage(), + assistantMessage([textPart("prt_bottom_follow_text", "Working")], { completed: false }), + ], + settings: { editToolPartsExpanded: true }, + reducedMotion: true, + }) + const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) + await scroller.evaluate((element) => { + element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 300) + element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: 300 })) + element.scrollTop = element.scrollHeight + }) + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(1) + + await timeline.send(partUpdated(toolPart(toolID, "patch", "running", input))) + await timeline.send( + partUpdated( + toolPart(toolID, "patch", "completed", input, { + metadata: { + files: [ + { + file: "src/edit.ts", + status: "modified", + patch: createTwoFilesPatch( + "a/src/edit.ts", + "b/src/edit.ts", + Array.from({ length: 40 }, (_, index) => `export const value${index} = ${index}\n`).join(""), + Array.from({ length: 40 }, (_, index) => `export const value${index} = ${index + 1}\n`).join(""), + ), + additions: 40, + deletions: 40, + }, + ], + }, + }), + ), + ) + + await timeline.waitForPart(toolID) + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(1) +}) + test("does not reverse visible rows when the user wheels during shell remeasurement", async ({ page }, testInfo) => { const shellID = "prt_wheel_01_shell" const followingID = "prt_wheel_02_following" diff --git a/packages/app/src/session/timeline/virtualizer.tsx b/packages/app/src/session/timeline/virtualizer.tsx index 8da48eeb589..5a822abe24b 100644 --- a/packages/app/src/session/timeline/virtualizer.tsx +++ b/packages/app/src/session/timeline/virtualizer.tsx @@ -74,7 +74,9 @@ export function createTimelineVirtualizer(input: Input) { let prependLoading = false let resizePinnedIndexes: number[] = [] let resizePinFrame: number | undefined + let gestureAnchorFrame: number | undefined let virtualContent: HTMLDivElement | undefined + let scrollTop = 0 const clearPrependAnchor = () => { prependLoading = false @@ -178,12 +180,28 @@ export function createTimelineVirtualizer(input: Input) { }) const resizeItem = virtualizer.resizeItem let resizeAnchorScheduled = false + const anchorAfterGesture = () => { + if (gestureAnchorFrame !== undefined) return + const apply = () => { + gestureAnchorFrame = undefined + if (input.hasScrollGesture()) { + gestureAnchorFrame = requestAnimationFrame(apply) + return + } + if (input.shouldAnchorBottom()) virtualizer.scrollToEnd() + } + gestureAnchorFrame = requestAnimationFrame(apply) + } const anchorResizedBottom = () => { - if (resizeAnchorScheduled || input.hasScrollGesture()) return + if (resizeAnchorScheduled) return resizeAnchorScheduled = true queueMicrotask(() => { resizeAnchorScheduled = false - if (!input.shouldAnchorBottom() || input.hasScrollGesture()) return + if (input.hasScrollGesture()) { + anchorAfterGesture() + return + } + if (!input.shouldAnchorBottom()) return virtualizer.scrollToEnd() }) } @@ -244,7 +262,11 @@ export function createTimelineVirtualizer(input: Input) { const maybeAnchorBottom = () => { if (rows().length === 0) return - if (!input.shouldAnchorBottom() || input.hasScrollGesture()) return + if (input.hasScrollGesture()) { + anchorAfterGesture() + return + } + if (!input.shouldAnchorBottom()) return if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame) clearPrependAnchor() if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame) @@ -265,6 +287,7 @@ export function createTimelineVirtualizer(input: Input) { const bindListRoot = (root: HTMLDivElement) => { if (root === listRoot()) return setListRoot(root) + scrollTop = root.scrollTop input.setScrollRef(root) } @@ -324,13 +347,17 @@ export function createTimelineVirtualizer(input: Input) { } const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => { + const root = event.currentTarget + const movedUp = root.scrollTop < scrollTop - 0.5 + scrollTop = root.scrollTop if (prependLoading) updatePrependAnchor() - input.onScheduleScrollState(event.currentTarget) + input.onScheduleScrollState(root) input.onHistoryScroll() if (!input.hasScrollGesture()) return + if (!movedUp && root.scrollHeight - root.clientHeight - root.scrollTop >= 10) return input.onUserScroll() input.onAutoScrollHandleScroll() - input.onMarkScrollGesture(event.currentTarget) + input.onMarkScrollGesture(root) } function View(props: ViewProps) { @@ -463,6 +490,7 @@ export function createTimelineVirtualizer(input: Input) { cache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } }) while (cache.size > 16) cache.delete(cache.keys().next().value!) if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame) + if (gestureAnchorFrame !== undefined) cancelAnimationFrame(gestureAnchorFrame) if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame) input.setScrollRef(undefined) input.setRevealMessage?.(() => {})