diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index 7f47c7df6..ffb6d8c37 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -664,6 +664,11 @@ export const ThreadViewport = forwardRef { + 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", diff --git a/webui/src/components/thread/thread-motion.ts b/webui/src/components/thread/thread-motion.ts index b56a48876..62b077412 100644 --- a/webui/src/components/thread/thread-motion.ts +++ b/webui/src/components/thread/thread-motion.ts @@ -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; } diff --git a/webui/src/tests/thread-motion.test.ts b/webui/src/tests/thread-motion.test.ts index 21b3fcb1d..50ace92c1 100644 --- a/webui/src/tests/thread-motion.test.ts +++ b/webui/src/tests/thread-motion.test.ts @@ -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, diff --git a/webui/src/tests/thread-viewport.test.tsx b/webui/src/tests/thread-viewport.test.tsx index ab765013f..c09537b3d 100644 --- a/webui/src/tests/thread-viewport.test.tsx +++ b/webui/src/tests/thread-viewport.test.tsx @@ -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) => ( + } + 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 {