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
5 changed files with 232 additions and 3 deletions
+143
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");