fix(webui): open threads at latest message (#5142)

This commit is contained in:
chengyongru 2026-07-28 18:52:34 +08:00 committed by GitHub
parent 0c6c0438d4
commit 24a392b671
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 232 additions and 3 deletions

View File

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

View File

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

View File

@ -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();

View File

@ -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", () => {

View File

@ -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(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div />}
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(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div />}
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(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div />}
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");