fix(webui): keep streaming tail visible (#5140)
This commit is contained in:
@@ -39,74 +39,57 @@ function cameraHarness(prefersReducedMotion = false) {
|
||||
}
|
||||
|
||||
describe("ThreadCameraController", () => {
|
||||
it("responds immediately, then eases out as a static target gets closer", () => {
|
||||
const { camera, viewport, advance } = cameraHarness();
|
||||
it("pins automatic follow in the geometry frame without camera debt", () => {
|
||||
const { camera, viewport, frames } = cameraHarness();
|
||||
|
||||
camera.followTo(60);
|
||||
advance(16);
|
||||
const firstStep = viewport.scrollTop;
|
||||
advance(16);
|
||||
const secondStep = viewport.scrollTop - firstStep;
|
||||
advance(16);
|
||||
const thirdStep = viewport.scrollTop - firstStep - secondStep;
|
||||
expect(camera.followTo(60)).toBe("settled");
|
||||
|
||||
expect(firstStep).toBeGreaterThan(0);
|
||||
expect(secondStep).toBeGreaterThan(0);
|
||||
expect(thirdStep).toBeGreaterThan(0);
|
||||
expect(secondStep).toBeLessThan(firstStep);
|
||||
expect(thirdStep).toBeLessThan(secondStep);
|
||||
expect(viewport.scrollTop).toBe(60);
|
||||
expect(frames).toHaveLength(0);
|
||||
expect(camera.isFollowing()).toBe(false);
|
||||
});
|
||||
|
||||
it("retargets an active follow without adding another loop", () => {
|
||||
it("retargets active history navigation without adding another loop", () => {
|
||||
const { camera, viewport, frames, advance } = cameraHarness();
|
||||
|
||||
expect(camera.followTo(100)).toBe("started");
|
||||
expect(camera.navigateTo(100)).toBe("started");
|
||||
expect(frames).toHaveLength(1);
|
||||
advance(16);
|
||||
expect(frames).toHaveLength(1);
|
||||
|
||||
expect(camera.followTo(180)).toBe("retargeted");
|
||||
expect(camera.navigateTo(180)).toBe("retargeted");
|
||||
expect(frames).toHaveLength(1);
|
||||
for (let frame = 0; frame < 120; frame += 1) advance(16);
|
||||
expect(viewport.scrollTop).toBe(180);
|
||||
});
|
||||
|
||||
it("tracks repeated target growth as one monotonic camera movement", () => {
|
||||
const { camera, viewport, advance } = cameraHarness();
|
||||
it("pins repeated automatic targets without accumulating lag", () => {
|
||||
const { camera, viewport, frames } = cameraHarness();
|
||||
|
||||
camera.followTo(80);
|
||||
advance(16);
|
||||
const first = viewport.scrollTop;
|
||||
expect(viewport.scrollTop).toBe(80);
|
||||
camera.followTo(140);
|
||||
advance(16);
|
||||
const second = viewport.scrollTop;
|
||||
expect(viewport.scrollTop).toBe(140);
|
||||
camera.followTo(220);
|
||||
advance(16);
|
||||
const third = viewport.scrollTop;
|
||||
|
||||
expect(first).toBeGreaterThan(0);
|
||||
expect(second).toBeGreaterThan(first);
|
||||
expect(third).toBeGreaterThan(second);
|
||||
expect(camera.isFollowing()).toBe(true);
|
||||
expect(viewport.scrollTop).toBe(220);
|
||||
expect(frames).toHaveLength(0);
|
||||
expect(camera.isFollowing()).toBe(false);
|
||||
});
|
||||
|
||||
it("uses a faster motion profile for explicit long-distance navigation", () => {
|
||||
const follow = cameraHarness();
|
||||
const navigation = cameraHarness();
|
||||
it("eases explicit long-distance navigation across frames", () => {
|
||||
const { camera, viewport, advance } = cameraHarness();
|
||||
|
||||
follow.camera.followTo(1_000);
|
||||
navigation.camera.navigateTo(1_000);
|
||||
follow.advance(16);
|
||||
navigation.advance(16);
|
||||
camera.navigateTo(1_000);
|
||||
advance(16);
|
||||
|
||||
expect(navigation.viewport.scrollTop).toBeGreaterThan(follow.viewport.scrollTop);
|
||||
expect(navigation.viewport.scrollTop).toBeLessThan(1_000);
|
||||
expect(viewport.scrollTop).toBeGreaterThan(0);
|
||||
expect(viewport.scrollTop).toBeLessThan(1_000);
|
||||
});
|
||||
|
||||
it("gives an immediate jump command priority over an active follow", () => {
|
||||
const { camera, viewport, scheduler, frames } = cameraHarness();
|
||||
|
||||
camera.followTo(240);
|
||||
camera.navigateTo(240);
|
||||
expect(frames).toHaveLength(1);
|
||||
camera.jumpTo(40);
|
||||
|
||||
@@ -116,12 +99,12 @@ describe("ThreadCameraController", () => {
|
||||
expect(frames).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("preserves spatial continuity with a shorter reduced-motion chase", () => {
|
||||
it("preserves spatial continuity with shorter reduced-motion navigation", () => {
|
||||
const regular = cameraHarness();
|
||||
const reduced = cameraHarness(true);
|
||||
|
||||
expect(regular.camera.followTo(240)).toBe("started");
|
||||
expect(reduced.camera.followTo(240)).toBe("started");
|
||||
expect(regular.camera.navigateTo(240)).toBe("started");
|
||||
expect(reduced.camera.navigateTo(240)).toBe("started");
|
||||
|
||||
regular.advance(16);
|
||||
reduced.advance(16);
|
||||
|
||||
@@ -36,7 +36,7 @@ function motionHarness(initial?: Partial<ThreadMotionGeometry>) {
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
jumpTo: vi.fn(),
|
||||
followTo: vi.fn(() => "started" as const),
|
||||
followTo: vi.fn(() => "settled" as const),
|
||||
isFollowing: vi.fn(() => cameraFollowing),
|
||||
navigateTo: vi.fn(() => {
|
||||
cameraFollowing = true;
|
||||
@@ -120,7 +120,7 @@ describe("ThreadMotionCoordinator", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("retargets repeated output growth without restarting camera ownership", () => {
|
||||
it("pins repeated output growth on each authoritative geometry frame", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
@@ -465,6 +465,83 @@ describe("ThreadMotionCoordinator", () => {
|
||||
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
||||
});
|
||||
|
||||
it("retargets smooth latest navigation before resuming automatic follow", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
advanceFrame,
|
||||
setCameraFollowing,
|
||||
setGeometry,
|
||||
} = motionHarness();
|
||||
coordinator.updateTurn({
|
||||
id: "turn-1",
|
||||
promptId: "prompt-1",
|
||||
hasOutput: true,
|
||||
});
|
||||
advanceFrame();
|
||||
coordinator.takeUserControl();
|
||||
camera.followTo.mockClear();
|
||||
camera.navigateTo.mockClear();
|
||||
|
||||
expect(coordinator.navigateLatestTo(1_400)).toBe("started");
|
||||
expect(coordinator.snapshot().mode).toBe("navigating-latest");
|
||||
|
||||
setGeometry({
|
||||
scrollTop: 900,
|
||||
scrollHeight: 2_100,
|
||||
});
|
||||
coordinator.invalidateGeometry();
|
||||
advanceFrame();
|
||||
|
||||
expect(camera.navigateTo.mock.calls).toEqual([[1_400], [1_600]]);
|
||||
expect(camera.followTo).not.toHaveBeenCalled();
|
||||
|
||||
setGeometry({ scrollTop: 1_600 });
|
||||
setCameraFollowing(false);
|
||||
expect(coordinator.observeScroll(true)).toBe("navigation");
|
||||
expect(coordinator.snapshot().mode).toBe("follow-output");
|
||||
|
||||
advanceFrame();
|
||||
expect(camera.followTo).toHaveBeenCalledWith(1_600);
|
||||
});
|
||||
|
||||
it("keeps latest navigation through completion and final layout growth", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
advanceFrame,
|
||||
setCameraFollowing,
|
||||
setGeometry,
|
||||
} = motionHarness();
|
||||
coordinator.updateTurn({
|
||||
id: "turn-1",
|
||||
promptId: "prompt-1",
|
||||
hasOutput: true,
|
||||
});
|
||||
advanceFrame();
|
||||
coordinator.takeUserControl();
|
||||
coordinator.navigateLatestTo(1_400);
|
||||
camera.followTo.mockClear();
|
||||
camera.navigateTo.mockClear();
|
||||
|
||||
coordinator.completeTurn();
|
||||
setGeometry({
|
||||
scrollTop: 1_000,
|
||||
scrollHeight: 2_300,
|
||||
});
|
||||
coordinator.invalidateGeometry();
|
||||
advanceFrame();
|
||||
|
||||
expect(camera.navigateTo).toHaveBeenCalledWith(1_800);
|
||||
expect(camera.followTo).not.toHaveBeenCalled();
|
||||
expect(coordinator.snapshot().mode).toBe("navigating-latest");
|
||||
|
||||
setGeometry({ scrollTop: 1_800 });
|
||||
setCameraFollowing(false);
|
||||
expect(coordinator.observeScroll(true)).toBe("navigation");
|
||||
expect(coordinator.snapshot().mode).toBe("idle");
|
||||
});
|
||||
|
||||
it("pins a waiting prompt to the exact lower boundary across all layout changes", () => {
|
||||
const {
|
||||
camera,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
windowMessages,
|
||||
} from "@/components/thread/ThreadViewport";
|
||||
import { ThreadCameraController } from "@/components/thread/thread-camera";
|
||||
import { ThreadMotionCoordinator } from "@/components/thread/thread-motion";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
const messages: UIMessage[] = [
|
||||
@@ -819,6 +820,33 @@ describe("ThreadViewport", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("gives smooth scroll-to-bottom navigation ownership of the latest target", () => {
|
||||
const navigateLatestTo = vi.spyOn(
|
||||
ThreadMotionCoordinator.prototype,
|
||||
"navigateLatestTo",
|
||||
).mockReturnValue("started");
|
||||
const { container } = render(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming
|
||||
composer={<div>composer</div>}
|
||||
/>,
|
||||
);
|
||||
const scroller = getScroller(container);
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 2_400 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, writable: true, value: 0 },
|
||||
});
|
||||
|
||||
act(() => {
|
||||
dispatchUserScroll(scroller);
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scroll to bottom" }));
|
||||
|
||||
expect(navigateLatestTo).toHaveBeenCalledWith(1_800);
|
||||
});
|
||||
|
||||
it("pins the waiting boundary across composer and grid-track growth", async () => {
|
||||
const resizeObserver = stubResizeObserver();
|
||||
const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo");
|
||||
|
||||
Reference in New Issue
Block a user