fix(webui): keep streaming tail visible (#5140)

This commit is contained in:
chengyongru
2026-07-28 18:18:44 +08:00
committed by GitHub
parent 1faf0826f6
commit 76ab04ac48
6 changed files with 215 additions and 102 deletions
+26 -43
View File
@@ -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);
+79 -2
View File
@@ -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,
+28
View File
@@ -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");