mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 16:38:49 +00:00
fix(webui): open threads at latest message (#5142)
This commit is contained in:
parent
0c6c0438d4
commit
24a392b671
@ -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);
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user