fix(webui): preserve user scroll ownership near tail (#5193)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
<ThreadViewport
|
||||
messages={threaded}
|
||||
isStreaming
|
||||
composer={<div>composer</div>}
|
||||
/>,
|
||||
);
|
||||
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<HTMLElement>('[data-user-prompt-id="u2"]');
|
||||
expect(prompt).not.toBeNull();
|
||||
Object.defineProperty(prompt, "offsetTop", {
|
||||
configurable: true,
|
||||
value: 1_420,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<ThreadViewport
|
||||
messages={[...threaded, answer]}
|
||||
isStreaming
|
||||
composer={<div>composer</div>}
|
||||
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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user