From 24a392b671230854cbbdb96fdfd761463cc68cb3 Mon Sep 17 00:00:00 2001 From: chengyongru <61816729+chengyongru@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:52:34 +0800 Subject: [PATCH] fix(webui): open threads at latest message (#5142) --- webui/src/components/thread/thread-camera.ts | 10 +- webui/src/components/thread/thread-motion.ts | 15 +- webui/src/tests/thread-camera.test.ts | 18 +++ webui/src/tests/thread-motion.test.ts | 49 ++++++- webui/src/tests/thread-viewport.test.tsx | 143 +++++++++++++++++++ 5 files changed, 232 insertions(+), 3 deletions(-) diff --git a/webui/src/components/thread/thread-camera.ts b/webui/src/components/thread/thread-camera.ts index 7837cec71..29828fdb6 100644 --- a/webui/src/components/thread/thread-camera.ts +++ b/webui/src/components/thread/thread-camera.ts @@ -186,12 +186,20 @@ export class ThreadCameraController { } const deltaSeconds = deltaMs / 1000; - const nextTop = easeOutChase( + const easedTop = easeOutChase( current, this.target, deltaSeconds, motion, ); + // Some browsers quantize scrollTop writes to whole pixels. Keep the + // ease-out curve, but never let its subpixel tail round back to the same + // position forever. + const minimumStep = Math.min(1, Math.abs(remainingDistance)); + const nextTop = + Math.abs(easedTop - current) < minimumStep + ? current + Math.sign(remainingDistance) * minimumStep + : easedTop; const settled = Math.abs(this.target - nextTop) <= motion.settleDistancePx; this.write(viewport, settled ? this.target : nextTop); diff --git a/webui/src/components/thread/thread-motion.ts b/webui/src/components/thread/thread-motion.ts index 305d3327a..f4f4d6873 100644 --- a/webui/src/components/thread/thread-motion.ts +++ b/webui/src/components/thread/thread-motion.ts @@ -5,6 +5,7 @@ import type { type ThreadMotionMode = | "idle" + | "follow-latest" | "anchor-prompt" | "follow-output" | "follow-completion" @@ -14,6 +15,7 @@ type ThreadMotionMode = type AutomaticThreadMotionMode = | "idle" + | "follow-latest" | "anchor-prompt" | "follow-output"; @@ -39,6 +41,11 @@ const THREAD_MOTION_TRANSITIONS: Readonly< "navigate-latest": "navigating-latest", "navigate-history": "navigating-history", "user-scroll": "browsing-history", + "resume-follow": "current-automatic-mode", + }, + "follow-latest": { + "navigate-history": "navigating-history", + "user-scroll": "browsing-history", }, "anchor-prompt": { "navigate-latest": "navigating-latest", @@ -350,7 +357,7 @@ export class ThreadMotionCoordinator { } private automaticMode(): AutomaticThreadMotionMode { - if (!this.turn.id) return "idle"; + if (!this.turn.id) return "follow-latest"; return this.promptPositioned && this.turn.hasOutput ? "follow-output" : "anchor-prompt"; @@ -408,6 +415,12 @@ export class ThreadMotionCoordinator { this.followGeometry(geometry); return; } + if (this.mode === "follow-latest") { + if (geometry.maxScrollTop - geometry.scrollTop > GEOMETRY_EPSILON_PX) { + this.camera.jumpTo(geometry.maxScrollTop); + } + return; + } if (this.isHistoryMode() || !this.turn.id) return; if (!this.turn.promptId && this.turn.entry !== "restored") { this.mode = "anchor-prompt"; diff --git a/webui/src/tests/thread-camera.test.ts b/webui/src/tests/thread-camera.test.ts index 60043a911..e5a606894 100644 --- a/webui/src/tests/thread-camera.test.ts +++ b/webui/src/tests/thread-camera.test.ts @@ -86,6 +86,24 @@ describe("ThreadCameraController", () => { expect(viewport.scrollTop).toBeLessThan(1_000); }); + it("settles exactly when the viewport quantizes subpixel tail movement", () => { + const { camera, viewport, advance } = cameraHarness(); + let quantizedTop = 0; + Object.defineProperty(viewport, "scrollTop", { + configurable: true, + get: () => quantizedTop, + set: (value: number) => { + quantizedTop = Math.floor(value); + }, + }); + + expect(camera.navigateTo(10)).toBe("started"); + for (let frame = 0; frame < 60; frame += 1) advance(16); + + expect(camera.isFollowing()).toBe(false); + expect(viewport.scrollTop).toBe(10); + }); + it("gives an immediate jump command priority over an active follow", () => { const { camera, viewport, scheduler, frames } = cameraHarness(); diff --git a/webui/src/tests/thread-motion.test.ts b/webui/src/tests/thread-motion.test.ts index 9cb8aa8ec..39af09321 100644 --- a/webui/src/tests/thread-motion.test.ts +++ b/webui/src/tests/thread-motion.test.ts @@ -343,6 +343,43 @@ describe("ThreadMotionCoordinator", () => { expect(camera.followTo).toHaveBeenCalledWith(1_600); }); + it("keeps an explicitly resumed idle thread pinned to its latest bottom without animating", () => { + const { + camera, + coordinator, + advanceFrame, + setGeometry, + } = motionHarness(); + + coordinator.resumeAutoFollow(); + advanceFrame(); + expect(camera.jumpTo).toHaveBeenLastCalledWith(1_400); + expect(coordinator.snapshot().mode).toBe("follow-latest"); + + camera.jumpTo.mockClear(); + setGeometry({ + scrollTop: 1_400, + scrollHeight: 2_000, + }); + coordinator.invalidateGeometry(); + advanceFrame(); + + expect(camera.jumpTo).toHaveBeenCalledWith(1_500); + expect(camera.followTo).not.toHaveBeenCalled(); + + coordinator.takeUserControl(); + camera.jumpTo.mockClear(); + setGeometry({ + scrollTop: 300, + scrollHeight: 2_100, + }); + coordinator.invalidateGeometry(); + advanceFrame(); + + expect(camera.jumpTo).not.toHaveBeenCalled(); + expect(coordinator.snapshot().mode).toBe("browsing-history"); + }); + it("treats scroll events as observations until explicit user intent takes control", () => { const { camera, @@ -539,7 +576,17 @@ describe("ThreadMotionCoordinator", () => { setGeometry({ scrollTop: 1_800 }); setCameraFollowing(false); expect(coordinator.observeScroll(true)).toBe("navigation"); - expect(coordinator.snapshot().mode).toBe("idle"); + expect(coordinator.snapshot().mode).toBe("follow-latest"); + + camera.jumpTo.mockClear(); + setGeometry({ + scrollTop: 1_800, + scrollHeight: 2_400, + }); + coordinator.invalidateGeometry(); + advanceFrame(); + + expect(camera.jumpTo).toHaveBeenCalledWith(1_900); }); it("pins a waiting prompt to the exact lower boundary across all layout changes", () => { diff --git a/webui/src/tests/thread-viewport.test.tsx b/webui/src/tests/thread-viewport.test.tsx index 2d1394dbb..9187d54d5 100644 --- a/webui/src/tests/thread-viewport.test.tsx +++ b/webui/src/tests/thread-viewport.test.tsx @@ -1637,6 +1637,149 @@ describe("ThreadViewport", () => { await waitFor(() => expect(scroller.scrollTop).toBe(1800)); }); + it("pins an opened conversation to late layout growth without animating", async () => { + const resizeObserver = stubResizeObserver(); + const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo"); + const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo"); + + try { + const { container, rerender } = render( + } + conversationKey="chat-a" + />, + ); + const scroller = getScroller(container); + Object.defineProperties(scroller, { + scrollHeight: { configurable: true, value: 2400 }, + clientHeight: { configurable: true, value: 600 }, + scrollTop: { configurable: true, writable: true, value: 300 }, + }); + act(() => { + dispatchUserScroll(scroller); + }); + + rerender( + } + conversationKey="chat-b" + />, + ); + await waitFor(() => expect(scroller.scrollTop).toBe(1800)); + jumpTo.mockClear(); + followTo.mockClear(); + + const messageRegion = screen.getByTestId("thread-message-region"); + const messageContent = messageRegion.firstElementChild; + expect(messageContent).not.toBeNull(); + const contentObserver = resizeObserver.observers.find( + (observer) => observer.elements.includes(messageContent!), + ); + expect(contentObserver).toBeDefined(); + + Object.defineProperty(scroller, "scrollHeight", { + configurable: true, + value: 3000, + }); + act(() => { + contentObserver!.callback([], contentObserver as unknown as ResizeObserver); + }); + await flushAnimationFrame(); + + expect(jumpTo).toHaveBeenCalledWith(2400); + expect(scroller.scrollTop).toBe(2400); + expect(followTo).not.toHaveBeenCalled(); + } finally { + jumpTo.mockRestore(); + followTo.mockRestore(); + resizeObserver.restore(); + } + }); + + it("animates the bottom button target and pins later layout growth", async () => { + const resizeObserver = stubResizeObserver(); + const navigateLatestTo = vi.spyOn( + ThreadMotionCoordinator.prototype, + "navigateLatestTo", + ); + const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo"); + const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo"); + + try { + const { container } = render( + } + conversationKey="chat-a" + />, + ); + const scroller = getScroller(container); + Object.defineProperties(scroller, { + scrollHeight: { configurable: true, value: 2400 }, + clientHeight: { configurable: true, value: 600 }, + scrollTop: { configurable: true, writable: true, value: 300 }, + }); + act(() => { + dispatchUserScroll(scroller); + }); + + navigateLatestTo.mockClear(); + jumpTo.mockClear(); + followTo.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Scroll to bottom" })); + expect(navigateLatestTo).toHaveBeenCalledWith(1800); + expect(scroller.scrollTop).toBe(300); + + const messageRegion = screen.getByTestId("thread-message-region"); + const messageContent = messageRegion.firstElementChild; + expect(messageContent).not.toBeNull(); + const contentObserver = resizeObserver.observers.find( + (observer) => observer.elements.includes(messageContent!), + ); + expect(contentObserver).toBeDefined(); + + Object.defineProperty(scroller, "scrollHeight", { + configurable: true, + value: 3000, + }); + act(() => { + contentObserver!.callback([], contentObserver as unknown as ResizeObserver); + }); + await waitFor(() => expect(scroller.scrollTop).toBe(2400)); + + expect(jumpTo).not.toHaveBeenCalled(); + expect(followTo).not.toHaveBeenCalled(); + + act(() => { + scroller.dispatchEvent(new Event("scroll")); + }); + await flushAnimationFrame(); + jumpTo.mockClear(); + + Object.defineProperty(scroller, "scrollHeight", { + configurable: true, + value: 3400, + }); + act(() => { + contentObserver!.callback([], contentObserver as unknown as ResizeObserver); + }); + await flushAnimationFrame(); + + expect(jumpTo).toHaveBeenCalledWith(2800); + expect(scroller.scrollTop).toBe(2800); + } finally { + navigateLatestTo.mockRestore(); + jumpTo.mockRestore(); + followTo.mockRestore(); + resizeObserver.restore(); + } + }); + it("waits for the next conversation's transcript before restoring its bottom", async () => { const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo"); const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo");