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");