mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 05:18:49 +03:00
fix(webui): preserve user scroll ownership near tail (#5193)
This commit is contained in:
@@ -542,7 +542,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
const near = distance < NEAR_BOTTOM_PX;
|
const near = distance < NEAR_BOTTOM_PX;
|
||||||
const owner = threadMotionRef.current?.observeScroll(near) ?? "automatic";
|
const owner = threadMotionRef.current?.observeScroll(near) ?? "automatic";
|
||||||
const logicallyAtBottom = owner === "automatic" || near;
|
const logicallyAtBottom = owner === "automatic" || (owner === "navigation" && near);
|
||||||
setAtBottom((current) =>
|
setAtBottom((current) =>
|
||||||
current === logicallyAtBottom ? current : logicallyAtBottom,
|
current === logicallyAtBottom ? current : logicallyAtBottom,
|
||||||
);
|
);
|
||||||
@@ -557,6 +557,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
if (!direction) return;
|
if (!direction) return;
|
||||||
threadMotionRef.current?.handleUserScrollIntent(
|
threadMotionRef.current?.handleUserScrollIntent(
|
||||||
canScrollInDirection(el, direction),
|
canScrollInDirection(el, direction),
|
||||||
|
direction === "forward",
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const handleWheel = (event: WheelEvent) => {
|
const handleWheel = (event: WheelEvent) => {
|
||||||
@@ -572,20 +573,21 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
const handlePointerDown = (event: PointerEvent) => {
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
if (event.button === 0 && event.target === el) yieldCameraToUser();
|
if (event.button === 0 && event.target === el) yieldCameraToUser();
|
||||||
};
|
};
|
||||||
let touchStartY: number | null = null;
|
let lastTouchY: number | null = null;
|
||||||
const handleTouchStart = (event: TouchEvent) => {
|
const handleTouchStart = (event: TouchEvent) => {
|
||||||
touchStartY = event.touches[0]?.clientY ?? null;
|
lastTouchY = event.touches[0]?.clientY ?? null;
|
||||||
};
|
};
|
||||||
const handleTouchMove = (event: TouchEvent) => {
|
const handleTouchMove = (event: TouchEvent) => {
|
||||||
const currentY = event.touches[0]?.clientY;
|
const currentY = event.touches[0]?.clientY;
|
||||||
const scrollDeltaY =
|
const scrollDeltaY =
|
||||||
touchStartY !== null && currentY !== undefined
|
lastTouchY !== null && currentY !== undefined
|
||||||
? touchStartY - currentY
|
? lastTouchY - currentY
|
||||||
: 0;
|
: 0;
|
||||||
|
lastTouchY = currentY ?? null;
|
||||||
handleDirectionalInput(directionFromDelta(scrollDeltaY));
|
handleDirectionalInput(directionFromDelta(scrollDeltaY));
|
||||||
};
|
};
|
||||||
const handleTouchEnd = () => {
|
const handleTouchEnd = () => {
|
||||||
touchStartY = null;
|
lastTouchY = null;
|
||||||
};
|
};
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -168,6 +168,9 @@ export class ThreadMotionCoordinator {
|
|||||||
private measurementFrameId: number | null = null;
|
private measurementFrameId: number | null = null;
|
||||||
private geometryDirty = false;
|
private geometryDirty = false;
|
||||||
private composerInputDuringTurn = false;
|
private composerInputDuringTurn = false;
|
||||||
|
// A user leaving the live tail must first move beyond the near-bottom
|
||||||
|
// boundary, or explicitly reverse toward latest, before follow can resume.
|
||||||
|
private resumeFollowArmed = false;
|
||||||
|
|
||||||
constructor(options: ThreadMotionCoordinatorOptions) {
|
constructor(options: ThreadMotionCoordinatorOptions) {
|
||||||
this.camera = options.camera;
|
this.camera = options.camera;
|
||||||
@@ -198,6 +201,7 @@ export class ThreadMotionCoordinator {
|
|||||||
if (isNewTurn) {
|
if (isNewTurn) {
|
||||||
this.camera.cancel();
|
this.camera.cancel();
|
||||||
this.composerInputDuringTurn = false;
|
this.composerInputDuringTurn = false;
|
||||||
|
this.resumeFollowArmed = false;
|
||||||
this.promptPositioned = turn.entry === "restored";
|
this.promptPositioned = turn.entry === "restored";
|
||||||
this.mode = this.promptPositioned && turn.hasOutput
|
this.mode = this.promptPositioned && turn.hasOutput
|
||||||
? "follow-output"
|
? "follow-output"
|
||||||
@@ -249,15 +253,31 @@ export class ThreadMotionCoordinator {
|
|||||||
this.handleUserScrollIntent(true);
|
this.handleUserScrollIntent(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleUserScrollIntent(canScroll: boolean): void {
|
handleUserScrollIntent(canScroll: boolean, towardLatest = false): void {
|
||||||
|
if (this.mode === "browsing-history" && towardLatest && !canScroll) {
|
||||||
|
this.transitionToAutoFollow(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const event = canScroll ? "user-scroll" : "boundary-scroll";
|
const event = canScroll ? "user-scroll" : "boundary-scroll";
|
||||||
if (!this.transition(event)) return;
|
const transitioned = this.transition(event);
|
||||||
|
if (this.mode === "browsing-history" && canScroll) {
|
||||||
|
this.resumeFollowArmed = towardLatest;
|
||||||
|
} else if (transitioned && this.mode === "browsing-history") {
|
||||||
|
this.resumeFollowArmed = false;
|
||||||
|
}
|
||||||
|
if (!transitioned) return;
|
||||||
this.camera.cancel();
|
this.camera.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
resumeAutoFollow(): void {
|
resumeAutoFollow(): void {
|
||||||
|
this.transitionToAutoFollow(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private transitionToAutoFollow(cancelCamera: boolean): void {
|
||||||
if (!this.transition("resume-follow")) return;
|
if (!this.transition("resume-follow")) return;
|
||||||
this.camera.cancel();
|
this.resumeFollowArmed = false;
|
||||||
|
if (cancelCamera) this.camera.cancel();
|
||||||
|
this.onAutoFollow?.();
|
||||||
this.invalidateGeometry();
|
this.invalidateGeometry();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,11 +337,19 @@ export class ThreadMotionCoordinator {
|
|||||||
case "navigating-history":
|
case "navigating-history":
|
||||||
if (!this.camera.isFollowing()) {
|
if (!this.camera.isFollowing()) {
|
||||||
this.transition("navigation-settled");
|
this.transition("navigation-settled");
|
||||||
if (nearBottom) this.resumeAutoFollow();
|
if (nearBottom) {
|
||||||
|
this.resumeAutoFollow();
|
||||||
|
} else {
|
||||||
|
this.resumeFollowArmed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return "navigation";
|
return "navigation";
|
||||||
case "browsing-history":
|
case "browsing-history":
|
||||||
if (!nearBottom) return "user";
|
if (!nearBottom) {
|
||||||
|
this.resumeFollowArmed = true;
|
||||||
|
return "user";
|
||||||
|
}
|
||||||
|
if (!this.resumeFollowArmed) return "user";
|
||||||
this.resumeAutoFollow();
|
this.resumeAutoFollow();
|
||||||
return "automatic";
|
return "automatic";
|
||||||
default:
|
default:
|
||||||
@@ -339,6 +367,7 @@ export class ThreadMotionCoordinator {
|
|||||||
this.camera.cancel();
|
this.camera.cancel();
|
||||||
this.turn = { id: null, promptId: null, hasOutput: false };
|
this.turn = { id: null, promptId: null, hasOutput: false };
|
||||||
this.composerInputDuringTurn = false;
|
this.composerInputDuringTurn = false;
|
||||||
|
this.resumeFollowArmed = false;
|
||||||
this.mode = "idle";
|
this.mode = "idle";
|
||||||
this.promptPositioned = false;
|
this.promptPositioned = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -410,6 +410,9 @@ describe("ThreadMotionCoordinator", () => {
|
|||||||
expect(camera.jumpTo).toHaveBeenCalledWith(780);
|
expect(camera.jumpTo).toHaveBeenCalledWith(780);
|
||||||
|
|
||||||
coordinator.takeUserControl();
|
coordinator.takeUserControl();
|
||||||
|
expect(coordinator.observeScroll(true)).toBe("user");
|
||||||
|
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
||||||
|
|
||||||
expect(coordinator.observeScroll(false)).toBe("user");
|
expect(coordinator.observeScroll(false)).toBe("user");
|
||||||
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
||||||
|
|
||||||
@@ -417,6 +420,57 @@ describe("ThreadMotionCoordinator", () => {
|
|||||||
expect(coordinator.snapshot().mode).toBe("anchor-prompt");
|
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", () => {
|
it("preserves history browsing when an active turn is cleared", () => {
|
||||||
const {
|
const {
|
||||||
camera,
|
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 () => {
|
it("keeps the scroll-to-bottom button above a growing composer", async () => {
|
||||||
const resizeObserver = stubResizeObserver();
|
const resizeObserver = stubResizeObserver();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user