diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index 5bf74e59a..936ba2af0 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -542,7 +542,7 @@ export const ThreadViewport = forwardRef current === logicallyAtBottom ? current : logicallyAtBottom, ); @@ -557,6 +557,7 @@ export const ThreadViewport = forwardRef { @@ -572,20 +573,21 @@ export const ThreadViewport = forwardRef { if (event.button === 0 && event.target === el) yieldCameraToUser(); }; - let touchStartY: number | null = null; + let lastTouchY: number | null = null; const handleTouchStart = (event: TouchEvent) => { - touchStartY = event.touches[0]?.clientY ?? null; + lastTouchY = event.touches[0]?.clientY ?? null; }; const handleTouchMove = (event: TouchEvent) => { const currentY = event.touches[0]?.clientY; const scrollDeltaY = - touchStartY !== null && currentY !== undefined - ? touchStartY - currentY + lastTouchY !== null && currentY !== undefined + ? lastTouchY - currentY : 0; + lastTouchY = currentY ?? null; handleDirectionalInput(directionFromDelta(scrollDeltaY)); }; const handleTouchEnd = () => { - touchStartY = null; + lastTouchY = null; }; const handleKeyDown = (event: KeyboardEvent) => { if ( diff --git a/webui/src/components/thread/thread-motion.ts b/webui/src/components/thread/thread-motion.ts index f4f4d6873..6c29de8db 100644 --- a/webui/src/components/thread/thread-motion.ts +++ b/webui/src/components/thread/thread-motion.ts @@ -168,6 +168,9 @@ export class ThreadMotionCoordinator { private measurementFrameId: number | null = null; private geometryDirty = false; private composerInputDuringTurn = false; + // A user leaving the live tail must first move beyond the near-bottom + // boundary, or explicitly reverse toward latest, before follow can resume. + private resumeFollowArmed = false; constructor(options: ThreadMotionCoordinatorOptions) { this.camera = options.camera; @@ -198,6 +201,7 @@ export class ThreadMotionCoordinator { if (isNewTurn) { this.camera.cancel(); this.composerInputDuringTurn = false; + this.resumeFollowArmed = false; this.promptPositioned = turn.entry === "restored"; this.mode = this.promptPositioned && turn.hasOutput ? "follow-output" @@ -249,15 +253,31 @@ export class ThreadMotionCoordinator { this.handleUserScrollIntent(true); } - handleUserScrollIntent(canScroll: boolean): void { + handleUserScrollIntent(canScroll: boolean, towardLatest = false): void { + if (this.mode === "browsing-history" && towardLatest && !canScroll) { + this.transitionToAutoFollow(false); + return; + } const event = canScroll ? "user-scroll" : "boundary-scroll"; - if (!this.transition(event)) return; + const transitioned = this.transition(event); + if (this.mode === "browsing-history" && canScroll) { + this.resumeFollowArmed = towardLatest; + } else if (transitioned && this.mode === "browsing-history") { + this.resumeFollowArmed = false; + } + if (!transitioned) return; this.camera.cancel(); } resumeAutoFollow(): void { + this.transitionToAutoFollow(true); + } + + private transitionToAutoFollow(cancelCamera: boolean): void { if (!this.transition("resume-follow")) return; - this.camera.cancel(); + this.resumeFollowArmed = false; + if (cancelCamera) this.camera.cancel(); + this.onAutoFollow?.(); this.invalidateGeometry(); } @@ -317,11 +337,19 @@ export class ThreadMotionCoordinator { case "navigating-history": if (!this.camera.isFollowing()) { this.transition("navigation-settled"); - if (nearBottom) this.resumeAutoFollow(); + if (nearBottom) { + this.resumeAutoFollow(); + } else { + this.resumeFollowArmed = true; + } } return "navigation"; case "browsing-history": - if (!nearBottom) return "user"; + if (!nearBottom) { + this.resumeFollowArmed = true; + return "user"; + } + if (!this.resumeFollowArmed) return "user"; this.resumeAutoFollow(); return "automatic"; default: @@ -339,6 +367,7 @@ export class ThreadMotionCoordinator { this.camera.cancel(); this.turn = { id: null, promptId: null, hasOutput: false }; this.composerInputDuringTurn = false; + this.resumeFollowArmed = false; this.mode = "idle"; this.promptPositioned = false; } diff --git a/webui/src/tests/thread-motion.test.ts b/webui/src/tests/thread-motion.test.ts index 39af09321..83a313c2c 100644 --- a/webui/src/tests/thread-motion.test.ts +++ b/webui/src/tests/thread-motion.test.ts @@ -410,6 +410,9 @@ describe("ThreadMotionCoordinator", () => { expect(camera.jumpTo).toHaveBeenCalledWith(780); coordinator.takeUserControl(); + expect(coordinator.observeScroll(true)).toBe("user"); + expect(coordinator.snapshot().mode).toBe("browsing-history"); + expect(coordinator.observeScroll(false)).toBe("user"); expect(coordinator.snapshot().mode).toBe("browsing-history"); @@ -417,6 +420,57 @@ describe("ThreadMotionCoordinator", () => { expect(coordinator.snapshot().mode).toBe("anchor-prompt"); }); + it("resumes shallow history browsing when user intent turns toward latest", () => { + const { + camera, + coordinator, + advanceFrame, + } = motionHarness({ + scrollTop: 1_400, + }); + coordinator.updateTurn({ + id: "turn-1", + promptId: "prompt-1", + hasOutput: true, + }); + advanceFrame(); + camera.followTo.mockClear(); + + coordinator.handleUserScrollIntent(true); + expect(coordinator.observeScroll(true)).toBe("user"); + advanceFrame(); + expect(camera.followTo).not.toHaveBeenCalled(); + + coordinator.handleUserScrollIntent(true, true); + expect(coordinator.observeScroll(true)).toBe("automatic"); + expect(coordinator.snapshot().mode).toBe("follow-output"); + advanceFrame(); + expect(camera.followTo).toHaveBeenCalledWith(1_400); + }); + + it("resumes shallow history browsing from forward intent at the boundary", () => { + const { + advanceFrame, + coordinator, + onAutoFollow, + } = motionHarness({ + scrollTop: 1_400, + }); + coordinator.updateTurn({ + id: "turn-1", + promptId: "prompt-1", + hasOutput: true, + }); + advanceFrame(); + + coordinator.handleUserScrollIntent(true); + expect(coordinator.observeScroll(true)).toBe("user"); + + coordinator.handleUserScrollIntent(false, true); + expect(coordinator.snapshot().mode).toBe("follow-output"); + expect(onAutoFollow).toHaveBeenCalledTimes(1); + }); + it("preserves history browsing when an active turn is cleared", () => { const { camera, diff --git a/webui/src/tests/thread-viewport.test.tsx b/webui/src/tests/thread-viewport.test.tsx index 9187d54d5..faa0fdd0a 100644 --- a/webui/src/tests/thread-viewport.test.tsx +++ b/webui/src/tests/thread-viewport.test.tsx @@ -763,6 +763,101 @@ describe("ThreadViewport", () => { } }); + it("keeps shallow wheel and touch scrolling user-owned until intent reverses", async () => { + const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo"); + const threaded: UIMessage[] = [ + { id: "u1", role: "user", content: "old question", turnId: "turn-1", createdAt: 1 }, + { id: "a1", role: "assistant", content: "old answer", turnId: "turn-1", createdAt: 2 }, + { id: "u2", role: "user", content: "new question", turnId: "turn-2", createdAt: 3 }, + ]; + const answer: UIMessage = { + id: "a2", + role: "assistant", + content: "streaming answer", + turnId: "turn-2", + isStreaming: true, + createdAt: 4, + }; + const { container, rerender } = render( + composer} + />, + ); + const scroller = getScroller(container); + Object.defineProperties(scroller, { + scrollHeight: { configurable: true, value: 1_904 }, + clientHeight: { configurable: true, value: 500 }, + scrollTop: { configurable: true, writable: true, value: 1_404 }, + }); + const prompt = container.querySelector('[data-user-prompt-id="u2"]'); + expect(prompt).not.toBeNull(); + Object.defineProperty(prompt, "offsetTop", { + configurable: true, + value: 1_420, + }); + + rerender( + composer} + activeTurnId="turn-2" + activeTurnStartedHere + />, + ); + await flushAnimationFrame(); + followTo.mockClear(); + + act(() => { + fireEvent.wheel(scroller, { deltaY: -24 }); + scroller.scrollTop = 1_380; + scroller.dispatchEvent(new Event("scroll")); + }); + await flushAnimationFrame(); + + expect(followTo).not.toHaveBeenCalled(); + expect(scroller.scrollTop).toBe(1_380); + expect(screen.getByRole("button", { name: "Scroll to bottom" })).toBeInTheDocument(); + + act(() => { + scroller.scrollTop = 1_404; + scroller.dispatchEvent(new Event("scroll")); + fireEvent.wheel(scroller, { deltaY: 24 }); + }); + await flushAnimationFrame(); + + expect(followTo).toHaveBeenCalledWith(1_404); + expect(scroller.scrollTop).toBe(1_404); + expect(screen.queryByRole("button", { name: "Scroll to bottom" })) + .not.toBeInTheDocument(); + + followTo.mockClear(); + act(() => { + fireEvent.touchStart(scroller, { touches: [{ clientY: 300 }] }); + fireEvent.touchMove(scroller, { touches: [{ clientY: 324 }] }); + scroller.scrollTop = 1_380; + scroller.dispatchEvent(new Event("scroll")); + }); + await flushAnimationFrame(); + + expect(followTo).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Scroll to bottom" })).toBeInTheDocument(); + + act(() => { + fireEvent.touchMove(scroller, { touches: [{ clientY: 300 }] }); + scroller.scrollTop = 1_404; + scroller.dispatchEvent(new Event("scroll")); + fireEvent.touchEnd(scroller); + }); + await flushAnimationFrame(); + + expect(followTo).toHaveBeenCalledWith(1_404); + expect(screen.queryByRole("button", { name: "Scroll to bottom" })) + .not.toBeInTheDocument(); + }); + it("keeps the scroll-to-bottom button above a growing composer", async () => { const resizeObserver = stubResizeObserver();