fix(webui): prevent composer resize scroll jitter (#5121)

This commit is contained in:
chengyongru 2026-07-28 01:01:49 +08:00 committed by GitHub
parent b99e0f937e
commit 6bc454dab4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 172 additions and 2 deletions

View File

@ -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",

View File

@ -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;
}

View File

@ -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,

View File

@ -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 {