mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 17:38:35 +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
|
<div
|
||||||
ref={composerDockRef}
|
ref={composerDockRef}
|
||||||
data-testid="thread-composer-dock"
|
data-testid="thread-composer-dock"
|
||||||
|
onInputCapture={(event) => {
|
||||||
|
if (event.target instanceof HTMLTextAreaElement) {
|
||||||
|
threadMotionRef.current?.handleComposerInput();
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"row-start-2 z-10 w-full",
|
"row-start-2 z-10 w-full",
|
||||||
hasMessages ? "sticky bottom-0 bg-background" : "relative self-center",
|
hasMessages ? "sticky bottom-0 bg-background" : "relative self-center",
|
||||||
|
|||||||
@ -21,6 +21,7 @@ type ThreadMotionEvent =
|
|||||||
| "navigation-settled"
|
| "navigation-settled"
|
||||||
| "user-scroll"
|
| "user-scroll"
|
||||||
| "boundary-scroll"
|
| "boundary-scroll"
|
||||||
|
| "composer-input"
|
||||||
| "turn-completed"
|
| "turn-completed"
|
||||||
| "resume-follow";
|
| "resume-follow";
|
||||||
|
|
||||||
@ -49,6 +50,7 @@ const THREAD_MOTION_TRANSITIONS: Readonly<
|
|||||||
"follow-completion": {
|
"follow-completion": {
|
||||||
"navigate-history": "navigating-history",
|
"navigate-history": "navigating-history",
|
||||||
"user-scroll": "browsing-history",
|
"user-scroll": "browsing-history",
|
||||||
|
"composer-input": "idle",
|
||||||
},
|
},
|
||||||
"navigating-history": {
|
"navigating-history": {
|
||||||
"navigate-history": "navigating-history",
|
"navigate-history": "navigating-history",
|
||||||
@ -141,6 +143,7 @@ export class ThreadMotionCoordinator {
|
|||||||
private promptPositioned = false;
|
private promptPositioned = false;
|
||||||
private measurementFrameId: number | null = null;
|
private measurementFrameId: number | null = null;
|
||||||
private geometryDirty = false;
|
private geometryDirty = false;
|
||||||
|
private composerInputDuringTurn = false;
|
||||||
|
|
||||||
constructor(options: ThreadMotionCoordinatorOptions) {
|
constructor(options: ThreadMotionCoordinatorOptions) {
|
||||||
this.camera = options.camera;
|
this.camera = options.camera;
|
||||||
@ -170,6 +173,7 @@ export class ThreadMotionCoordinator {
|
|||||||
this.turn = turn;
|
this.turn = turn;
|
||||||
if (isNewTurn) {
|
if (isNewTurn) {
|
||||||
this.camera.cancel();
|
this.camera.cancel();
|
||||||
|
this.composerInputDuringTurn = 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"
|
||||||
@ -187,10 +191,17 @@ export class ThreadMotionCoordinator {
|
|||||||
}
|
}
|
||||||
// Protocol completion can share a React commit with the last large text
|
// Protocol completion can share a React commit with the last large text
|
||||||
// batch and begins the run-drawer exit. Keep camera ownership through
|
// batch and begins the run-drawer exit. Keep camera ownership through
|
||||||
// those final layout changes; only a new turn or explicit user navigation
|
// those final layout changes; only a new turn, explicit user navigation,
|
||||||
// may end completion follow.
|
// or input for the next prompt may end completion follow.
|
||||||
this.transition("turn-completed");
|
this.transition("turn-completed");
|
||||||
|
if (
|
||||||
|
this.composerInputDuringTurn
|
||||||
|
&& this.transition("composer-input")
|
||||||
|
) {
|
||||||
|
this.camera.cancel();
|
||||||
|
}
|
||||||
this.turn = { id: null, promptId: null, hasOutput: false };
|
this.turn = { id: null, promptId: null, hasOutput: false };
|
||||||
|
this.composerInputDuringTurn = false;
|
||||||
this.promptPositioned = false;
|
this.promptPositioned = false;
|
||||||
this.invalidateGeometry();
|
this.invalidateGeometry();
|
||||||
}
|
}
|
||||||
@ -201,6 +212,15 @@ export class ThreadMotionCoordinator {
|
|||||||
this.measurementFrameId = this.scheduler.request(this.flushGeometry);
|
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 {
|
takeUserControl(): void {
|
||||||
this.handleUserScrollIntent(true);
|
this.handleUserScrollIntent(true);
|
||||||
}
|
}
|
||||||
@ -275,6 +295,7 @@ export class ThreadMotionCoordinator {
|
|||||||
this.geometryDirty = false;
|
this.geometryDirty = false;
|
||||||
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.mode = "idle";
|
this.mode = "idle";
|
||||||
this.promptPositioned = false;
|
this.promptPositioned = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -192,6 +192,71 @@ describe("ThreadMotionCoordinator", () => {
|
|||||||
expect(camera.followTo).toHaveBeenLastCalledWith(1_950);
|
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", () => {
|
it("keeps turn identity stable when canonical replay replaces DOM ids", () => {
|
||||||
const {
|
const {
|
||||||
camera,
|
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 () => {
|
it("keeps the thread scrollport above a mobile soft keyboard", async () => {
|
||||||
const visualViewport = stubVisualViewport({ innerHeight: 800, height: 480 });
|
const visualViewport = stubVisualViewport({ innerHeight: 800, height: 480 });
|
||||||
try {
|
try {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user