mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 08:28:36 +00:00
fix(webui): prevent composer resize scroll jitter (#5121)
This commit is contained in:
parent
b99e0f937e
commit
6bc454dab4
@ -664,6 +664,11 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
<div
|
||||
ref={composerDockRef}
|
||||
data-testid="thread-composer-dock"
|
||||
onInputCapture={(event) => {
|
||||
if (event.target instanceof HTMLTextAreaElement) {
|
||||
threadMotionRef.current?.handleComposerInput();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"row-start-2 z-10 w-full",
|
||||
hasMessages ? "sticky bottom-0 bg-background" : "relative self-center",
|
||||
|
||||
@ -21,6 +21,7 @@ type ThreadMotionEvent =
|
||||
| "navigation-settled"
|
||||
| "user-scroll"
|
||||
| "boundary-scroll"
|
||||
| "composer-input"
|
||||
| "turn-completed"
|
||||
| "resume-follow";
|
||||
|
||||
@ -49,6 +50,7 @@ const THREAD_MOTION_TRANSITIONS: Readonly<
|
||||
"follow-completion": {
|
||||
"navigate-history": "navigating-history",
|
||||
"user-scroll": "browsing-history",
|
||||
"composer-input": "idle",
|
||||
},
|
||||
"navigating-history": {
|
||||
"navigate-history": "navigating-history",
|
||||
@ -141,6 +143,7 @@ export class ThreadMotionCoordinator {
|
||||
private promptPositioned = false;
|
||||
private measurementFrameId: number | null = null;
|
||||
private geometryDirty = false;
|
||||
private composerInputDuringTurn = false;
|
||||
|
||||
constructor(options: ThreadMotionCoordinatorOptions) {
|
||||
this.camera = options.camera;
|
||||
@ -170,6 +173,7 @@ export class ThreadMotionCoordinator {
|
||||
this.turn = turn;
|
||||
if (isNewTurn) {
|
||||
this.camera.cancel();
|
||||
this.composerInputDuringTurn = false;
|
||||
this.promptPositioned = turn.entry === "restored";
|
||||
this.mode = this.promptPositioned && turn.hasOutput
|
||||
? "follow-output"
|
||||
@ -187,10 +191,17 @@ export class ThreadMotionCoordinator {
|
||||
}
|
||||
// Protocol completion can share a React commit with the last large text
|
||||
// batch and begins the run-drawer exit. Keep camera ownership through
|
||||
// those final layout changes; only a new turn or explicit user navigation
|
||||
// may end completion follow.
|
||||
// those final layout changes; only a new turn, explicit user navigation,
|
||||
// or input for the next prompt may end completion follow.
|
||||
this.transition("turn-completed");
|
||||
if (
|
||||
this.composerInputDuringTurn
|
||||
&& this.transition("composer-input")
|
||||
) {
|
||||
this.camera.cancel();
|
||||
}
|
||||
this.turn = { id: null, promptId: null, hasOutput: false };
|
||||
this.composerInputDuringTurn = false;
|
||||
this.promptPositioned = false;
|
||||
this.invalidateGeometry();
|
||||
}
|
||||
@ -201,6 +212,15 @@ export class ThreadMotionCoordinator {
|
||||
this.measurementFrameId = this.scheduler.request(this.flushGeometry);
|
||||
}
|
||||
|
||||
handleComposerInput(): void {
|
||||
// Input and protocol completion can arrive in either order. Remember
|
||||
// editing that starts just before turn_end so the completion drawer
|
||||
// cannot reacquire the camera a few milliseconds later.
|
||||
if (this.turn.id) this.composerInputDuringTurn = true;
|
||||
if (!this.transition("composer-input")) return;
|
||||
this.camera.cancel();
|
||||
}
|
||||
|
||||
takeUserControl(): void {
|
||||
this.handleUserScrollIntent(true);
|
||||
}
|
||||
@ -275,6 +295,7 @@ export class ThreadMotionCoordinator {
|
||||
this.geometryDirty = false;
|
||||
this.camera.cancel();
|
||||
this.turn = { id: null, promptId: null, hasOutput: false };
|
||||
this.composerInputDuringTurn = false;
|
||||
this.mode = "idle";
|
||||
this.promptPositioned = false;
|
||||
}
|
||||
|
||||
@ -192,6 +192,71 @@ describe("ThreadMotionCoordinator", () => {
|
||||
expect(camera.followTo).toHaveBeenLastCalledWith(1_950);
|
||||
});
|
||||
|
||||
it("releases completion follow when composer editing begins", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
advanceFrame,
|
||||
setGeometry,
|
||||
} = motionHarness();
|
||||
coordinator.updateTurn({
|
||||
id: "turn-1",
|
||||
promptId: "prompt-1",
|
||||
hasOutput: true,
|
||||
});
|
||||
advanceFrame();
|
||||
coordinator.completeTurn();
|
||||
camera.jumpTo.mockClear();
|
||||
camera.followTo.mockClear();
|
||||
camera.cancel.mockClear();
|
||||
|
||||
coordinator.handleComposerInput();
|
||||
setGeometry({
|
||||
scrollTop: 1_400,
|
||||
scrollHeight: 1_940,
|
||||
composerHeight: 160,
|
||||
});
|
||||
coordinator.invalidateGeometry();
|
||||
advanceFrame();
|
||||
|
||||
expect(camera.cancel).toHaveBeenCalledTimes(1);
|
||||
expect(camera.jumpTo).not.toHaveBeenCalled();
|
||||
expect(camera.followTo).not.toHaveBeenCalled();
|
||||
expect(coordinator.snapshot().mode).toBe("idle");
|
||||
});
|
||||
|
||||
it("remembers composer editing that begins before turn completion", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
advanceFrame,
|
||||
setGeometry,
|
||||
} = motionHarness();
|
||||
coordinator.updateTurn({
|
||||
id: "turn-1",
|
||||
promptId: "prompt-1",
|
||||
hasOutput: true,
|
||||
});
|
||||
advanceFrame();
|
||||
camera.followTo.mockClear();
|
||||
camera.cancel.mockClear();
|
||||
|
||||
coordinator.handleComposerInput();
|
||||
expect(coordinator.snapshot().mode).toBe("follow-output");
|
||||
|
||||
coordinator.completeTurn();
|
||||
setGeometry({
|
||||
scrollTop: 1_400,
|
||||
scrollHeight: 1_940,
|
||||
composerHeight: 160,
|
||||
});
|
||||
advanceFrame();
|
||||
|
||||
expect(camera.cancel).toHaveBeenCalledTimes(1);
|
||||
expect(camera.followTo).not.toHaveBeenCalled();
|
||||
expect(coordinator.snapshot().mode).toBe("idle");
|
||||
});
|
||||
|
||||
it("keeps turn identity stable when canonical replay replaces DOM ids", () => {
|
||||
const {
|
||||
camera,
|
||||
|
||||
@ -867,6 +867,85 @@ describe("ThreadViewport", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("releases completion follow when single-line input starts before completion", async () => {
|
||||
const resizeObserver = stubResizeObserver();
|
||||
const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo");
|
||||
const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo");
|
||||
|
||||
try {
|
||||
const threaded: UIMessage[] = [
|
||||
{ id: "u1", role: "user", content: "question", turnId: "turn-1", createdAt: 1 },
|
||||
{ id: "a1", role: "assistant", content: "answer", turnId: "turn-1", createdAt: 2 },
|
||||
];
|
||||
const viewport = (isStreaming: boolean, activeTurnId?: string) => (
|
||||
<ThreadViewport
|
||||
messages={threaded}
|
||||
isStreaming={isStreaming}
|
||||
composer={<textarea aria-label="Message input" />}
|
||||
activeTurnId={activeTurnId}
|
||||
/>
|
||||
);
|
||||
const { container, rerender } = render(viewport(true));
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 1_200 },
|
||||
clientHeight: { configurable: true, value: 500 },
|
||||
scrollTop: { configurable: true, writable: true, value: 700 },
|
||||
});
|
||||
|
||||
rerender(viewport(true, "turn-1"));
|
||||
await flushAnimationFrame();
|
||||
jumpTo.mockClear();
|
||||
followTo.mockClear();
|
||||
|
||||
const composerDock = screen.getByTestId("thread-composer-dock");
|
||||
const composerObserver = resizeObserver.observers.find(
|
||||
(observer) => observer.elements.includes(composerDock),
|
||||
);
|
||||
expect(composerObserver).toBeDefined();
|
||||
Object.defineProperty(scroller, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 1_240,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
composerObserver!.callback(
|
||||
[{ target: composerDock }] as ResizeObserverEntry[],
|
||||
composerObserver as unknown as ResizeObserver,
|
||||
);
|
||||
});
|
||||
await flushAnimationFrame();
|
||||
|
||||
expect(followTo).toHaveBeenCalled();
|
||||
|
||||
followTo.mockClear();
|
||||
fireEvent.input(screen.getByLabelText("Message input"), {
|
||||
target: { value: "x" },
|
||||
});
|
||||
const scrollTopAfterInput = scroller.scrollTop;
|
||||
rerender(viewport(false));
|
||||
await flushAnimationFrame();
|
||||
expect(followTo).not.toHaveBeenCalled();
|
||||
Object.defineProperty(scroller, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 1_280,
|
||||
});
|
||||
act(() => {
|
||||
composerObserver!.callback(
|
||||
[{ target: composerDock }] as ResizeObserverEntry[],
|
||||
composerObserver as unknown as ResizeObserver,
|
||||
);
|
||||
});
|
||||
await flushAnimationFrame();
|
||||
|
||||
expect(jumpTo).not.toHaveBeenCalled();
|
||||
expect(followTo).not.toHaveBeenCalled();
|
||||
expect(scroller.scrollTop).toBe(scrollTopAfterInput);
|
||||
} finally {
|
||||
resizeObserver.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the thread scrollport above a mobile soft keyboard", async () => {
|
||||
const visualViewport = stubVisualViewport({ innerHeight: 800, height: 480 });
|
||||
try {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user