mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 18:08:34 +00:00
fix(webui): keep streaming tail visible (#5140)
This commit is contained in:
parent
1faf0826f6
commit
76ab04ac48
@ -275,7 +275,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
if (el) {
|
if (el) {
|
||||||
const top = Math.max(0, el.scrollHeight - el.clientHeight);
|
const top = Math.max(0, el.scrollHeight - el.clientHeight);
|
||||||
if (smooth) {
|
if (smooth) {
|
||||||
threadMotionRef.current?.animateTo(top);
|
threadMotionRef.current?.navigateLatestTo(top);
|
||||||
} else {
|
} else {
|
||||||
threadMotionRef.current?.jumpTo(top);
|
threadMotionRef.current?.jumpTo(top);
|
||||||
}
|
}
|
||||||
@ -289,7 +289,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
(smooth = false, options?: { force?: boolean }) => {
|
(smooth = false, options?: { force?: boolean }) => {
|
||||||
const force = options?.force ?? false;
|
const force = options?.force ?? false;
|
||||||
if (!force && threadMotionRef.current?.isAutoFollowPaused()) return;
|
if (!force && threadMotionRef.current?.isAutoFollowPaused()) return;
|
||||||
threadMotionRef.current?.resumeAutoFollow();
|
if (!smooth) threadMotionRef.current?.resumeAutoFollow();
|
||||||
scrollToBottomNow(smooth);
|
scrollToBottomNow(smooth);
|
||||||
},
|
},
|
||||||
[scrollToBottomNow],
|
[scrollToBottomNow],
|
||||||
|
|||||||
@ -4,7 +4,7 @@ interface ThreadCameraMotionProfile {
|
|||||||
* camera closes roughly 95% of an uncapped distance in three time constants.
|
* camera closes roughly 95% of an uncapped distance in three time constants.
|
||||||
*/
|
*/
|
||||||
responseTimeMs: number;
|
responseTimeMs: number;
|
||||||
/** Prevents a large completion batch from turning into a one-frame jump. */
|
/** Prevents a long explicit navigation from turning into a one-frame jump. */
|
||||||
maxSpeedPxPerSecond: number;
|
maxSpeedPxPerSecond: number;
|
||||||
/** Avoids spending frames chasing sub-pixel layout noise. */
|
/** Avoids spending frames chasing sub-pixel layout noise. */
|
||||||
settleDistancePx: number;
|
settleDistancePx: number;
|
||||||
@ -12,13 +12,6 @@ interface ThreadCameraMotionProfile {
|
|||||||
maxFrameDeltaMs: number;
|
maxFrameDeltaMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const THREAD_CAMERA_FOLLOW_MOTION: Readonly<ThreadCameraMotionProfile> = {
|
|
||||||
responseTimeMs: 90,
|
|
||||||
maxSpeedPxPerSecond: 1_200,
|
|
||||||
settleDistancePx: 0.5,
|
|
||||||
maxFrameDeltaMs: 50,
|
|
||||||
};
|
|
||||||
|
|
||||||
const THREAD_CAMERA_NAVIGATION_MOTION: Readonly<ThreadCameraMotionProfile> = {
|
const THREAD_CAMERA_NAVIGATION_MOTION: Readonly<ThreadCameraMotionProfile> = {
|
||||||
responseTimeMs: 110,
|
responseTimeMs: 110,
|
||||||
maxSpeedPxPerSecond: 12_000,
|
maxSpeedPxPerSecond: 12_000,
|
||||||
@ -26,18 +19,6 @@ const THREAD_CAMERA_NAVIGATION_MOTION: Readonly<ThreadCameraMotionProfile> = {
|
|||||||
maxFrameDeltaMs: 50,
|
maxFrameDeltaMs: 50,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Reduced motion still preserves spatial continuity. Snapping a long thread
|
|
||||||
* to its destination removes the very context that helps users understand
|
|
||||||
* where the viewport moved; this profile shortens that motion instead.
|
|
||||||
*/
|
|
||||||
const THREAD_CAMERA_REDUCED_MOTION: Readonly<ThreadCameraMotionProfile> = {
|
|
||||||
responseTimeMs: 55,
|
|
||||||
maxSpeedPxPerSecond: 2_400,
|
|
||||||
settleDistancePx: 0.5,
|
|
||||||
maxFrameDeltaMs: 50,
|
|
||||||
};
|
|
||||||
|
|
||||||
const THREAD_CAMERA_REDUCED_NAVIGATION_MOTION: Readonly<ThreadCameraMotionProfile> = {
|
const THREAD_CAMERA_REDUCED_NAVIGATION_MOTION: Readonly<ThreadCameraMotionProfile> = {
|
||||||
responseTimeMs: 45,
|
responseTimeMs: 45,
|
||||||
maxSpeedPxPerSecond: 24_000,
|
maxSpeedPxPerSecond: 24_000,
|
||||||
@ -63,12 +44,10 @@ interface ThreadCameraOptions {
|
|||||||
prefersReducedMotion?: () => boolean;
|
prefersReducedMotion?: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ThreadCameraMotionKind = "follow" | "navigation";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A time-based ease-out chase rather than a start/end tween. The target can
|
* A time-based ease-out chase rather than a start/end tween. The target can
|
||||||
* move on every streamed line without restarting a duration or adding another
|
* move during explicit history navigation without restarting a duration or
|
||||||
* frame loop.
|
* adding another frame loop.
|
||||||
*/
|
*/
|
||||||
function easeOutChase(
|
function easeOutChase(
|
||||||
current: number,
|
current: number,
|
||||||
@ -108,7 +87,6 @@ export class ThreadCameraController {
|
|||||||
private phase: "idle" | "following" = "idle";
|
private phase: "idle" | "following" = "idle";
|
||||||
private target = 0;
|
private target = 0;
|
||||||
private lastTimestamp: number | null = null;
|
private lastTimestamp: number | null = null;
|
||||||
private motionKind: ThreadCameraMotionKind = "follow";
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
getViewport: () => ThreadCameraViewport | null,
|
getViewport: () => ThreadCameraViewport | null,
|
||||||
@ -131,25 +109,31 @@ export class ThreadCameraController {
|
|||||||
this.write(viewport, this.target);
|
this.write(viewport, this.target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatic follow is a layout constraint, not navigation. Resolve it in
|
||||||
|
* the geometry frame so streamed content and viewport resizing cannot build
|
||||||
|
* up hidden travel below the visible tail.
|
||||||
|
*/
|
||||||
followTo(top: number): ThreadCameraFollowResult | null {
|
followTo(top: number): ThreadCameraFollowResult | null {
|
||||||
return this.moveTo(top, "follow");
|
const viewport = this.getViewport();
|
||||||
|
if (!viewport) return null;
|
||||||
|
this.cancel();
|
||||||
|
this.target = Math.max(0, top);
|
||||||
|
this.write(viewport, this.target);
|
||||||
|
return "settled";
|
||||||
}
|
}
|
||||||
|
|
||||||
navigateTo(top: number): ThreadCameraFollowResult | null {
|
navigateTo(top: number): ThreadCameraFollowResult | null {
|
||||||
return this.moveTo(top, "navigation");
|
return this.moveTo(top);
|
||||||
}
|
}
|
||||||
|
|
||||||
private moveTo(
|
private moveTo(top: number): ThreadCameraFollowResult | null {
|
||||||
top: number,
|
|
||||||
motionKind: ThreadCameraMotionKind,
|
|
||||||
): ThreadCameraFollowResult | null {
|
|
||||||
const viewport = this.getViewport();
|
const viewport = this.getViewport();
|
||||||
if (!viewport) return null;
|
if (!viewport) return null;
|
||||||
const current = viewport.scrollTop;
|
const current = viewport.scrollTop;
|
||||||
this.target = Math.max(0, top);
|
this.target = Math.max(0, top);
|
||||||
this.motionKind = motionKind;
|
|
||||||
|
|
||||||
const motion = this.currentMotion(motionKind);
|
const motion = this.currentMotion();
|
||||||
if (this.phase === "following") {
|
if (this.phase === "following") {
|
||||||
return "retargeted";
|
return "retargeted";
|
||||||
}
|
}
|
||||||
@ -171,7 +155,6 @@ export class ThreadCameraController {
|
|||||||
}
|
}
|
||||||
this.phase = "idle";
|
this.phase = "idle";
|
||||||
this.lastTimestamp = null;
|
this.lastTimestamp = null;
|
||||||
this.motionKind = "follow";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
@ -186,7 +169,7 @@ export class ThreadCameraController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const motion = this.currentMotion(this.motionKind);
|
const motion = this.currentMotion();
|
||||||
const previousTimestamp = this.lastTimestamp ?? timestamp - (1000 / 60);
|
const previousTimestamp = this.lastTimestamp ?? timestamp - (1000 / 60);
|
||||||
const deltaMs = Math.min(
|
const deltaMs = Math.min(
|
||||||
motion.maxFrameDeltaMs,
|
motion.maxFrameDeltaMs,
|
||||||
@ -220,15 +203,10 @@ export class ThreadCameraController {
|
|||||||
this.frameId = this.scheduler.request(this.advance);
|
this.frameId = this.scheduler.request(this.advance);
|
||||||
};
|
};
|
||||||
|
|
||||||
private currentMotion(kind: ThreadCameraMotionKind): ThreadCameraMotionProfile {
|
private currentMotion(): ThreadCameraMotionProfile {
|
||||||
if (kind === "navigation") {
|
|
||||||
return this.prefersReducedMotion()
|
|
||||||
? THREAD_CAMERA_REDUCED_NAVIGATION_MOTION
|
|
||||||
: THREAD_CAMERA_NAVIGATION_MOTION;
|
|
||||||
}
|
|
||||||
return this.prefersReducedMotion()
|
return this.prefersReducedMotion()
|
||||||
? THREAD_CAMERA_REDUCED_MOTION
|
? THREAD_CAMERA_REDUCED_NAVIGATION_MOTION
|
||||||
: THREAD_CAMERA_FOLLOW_MOTION;
|
: THREAD_CAMERA_NAVIGATION_MOTION;
|
||||||
}
|
}
|
||||||
|
|
||||||
private write(viewport: ThreadCameraViewport, top: number): void {
|
private write(viewport: ThreadCameraViewport, top: number): void {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ type ThreadMotionMode =
|
|||||||
| "anchor-prompt"
|
| "anchor-prompt"
|
||||||
| "follow-output"
|
| "follow-output"
|
||||||
| "follow-completion"
|
| "follow-completion"
|
||||||
|
| "navigating-latest"
|
||||||
| "navigating-history"
|
| "navigating-history"
|
||||||
| "browsing-history";
|
| "browsing-history";
|
||||||
|
|
||||||
@ -17,6 +18,7 @@ type AutomaticThreadMotionMode =
|
|||||||
| "follow-output";
|
| "follow-output";
|
||||||
|
|
||||||
type ThreadMotionEvent =
|
type ThreadMotionEvent =
|
||||||
|
| "navigate-latest"
|
||||||
| "navigate-history"
|
| "navigate-history"
|
||||||
| "navigation-settled"
|
| "navigation-settled"
|
||||||
| "user-scroll"
|
| "user-scroll"
|
||||||
@ -34,25 +36,38 @@ const THREAD_MOTION_TRANSITIONS: Readonly<
|
|||||||
>
|
>
|
||||||
> = {
|
> = {
|
||||||
idle: {
|
idle: {
|
||||||
|
"navigate-latest": "navigating-latest",
|
||||||
"navigate-history": "navigating-history",
|
"navigate-history": "navigating-history",
|
||||||
"user-scroll": "browsing-history",
|
"user-scroll": "browsing-history",
|
||||||
},
|
},
|
||||||
"anchor-prompt": {
|
"anchor-prompt": {
|
||||||
|
"navigate-latest": "navigating-latest",
|
||||||
"navigate-history": "navigating-history",
|
"navigate-history": "navigating-history",
|
||||||
"user-scroll": "browsing-history",
|
"user-scroll": "browsing-history",
|
||||||
"turn-completed": "follow-completion",
|
"turn-completed": "follow-completion",
|
||||||
},
|
},
|
||||||
"follow-output": {
|
"follow-output": {
|
||||||
|
"navigate-latest": "navigating-latest",
|
||||||
"navigate-history": "navigating-history",
|
"navigate-history": "navigating-history",
|
||||||
"user-scroll": "browsing-history",
|
"user-scroll": "browsing-history",
|
||||||
"turn-completed": "follow-completion",
|
"turn-completed": "follow-completion",
|
||||||
},
|
},
|
||||||
"follow-completion": {
|
"follow-completion": {
|
||||||
|
"navigate-latest": "navigating-latest",
|
||||||
"navigate-history": "navigating-history",
|
"navigate-history": "navigating-history",
|
||||||
"user-scroll": "browsing-history",
|
"user-scroll": "browsing-history",
|
||||||
"composer-input": "idle",
|
"composer-input": "idle",
|
||||||
},
|
},
|
||||||
|
"navigating-latest": {
|
||||||
|
"navigate-latest": "navigating-latest",
|
||||||
|
"navigate-history": "navigating-history",
|
||||||
|
"navigation-settled": "current-automatic-mode",
|
||||||
|
"user-scroll": "browsing-history",
|
||||||
|
"boundary-scroll": "browsing-history",
|
||||||
|
"resume-follow": "current-automatic-mode",
|
||||||
|
},
|
||||||
"navigating-history": {
|
"navigating-history": {
|
||||||
|
"navigate-latest": "navigating-latest",
|
||||||
"navigate-history": "navigating-history",
|
"navigate-history": "navigating-history",
|
||||||
"navigation-settled": "browsing-history",
|
"navigation-settled": "browsing-history",
|
||||||
"user-scroll": "browsing-history",
|
"user-scroll": "browsing-history",
|
||||||
@ -60,6 +75,7 @@ const THREAD_MOTION_TRANSITIONS: Readonly<
|
|||||||
"resume-follow": "current-automatic-mode",
|
"resume-follow": "current-automatic-mode",
|
||||||
},
|
},
|
||||||
"browsing-history": {
|
"browsing-history": {
|
||||||
|
"navigate-latest": "navigating-latest",
|
||||||
"navigate-history": "navigating-history",
|
"navigate-history": "navigating-history",
|
||||||
"resume-follow": "current-automatic-mode",
|
"resume-follow": "current-automatic-mode",
|
||||||
},
|
},
|
||||||
@ -124,9 +140,10 @@ function defaultScheduler(): ThreadMotionScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns the policy that turns discrete layout events into continuous camera
|
* Owns the policy that turns discrete layout events into automatic tail
|
||||||
* motion. Callers only invalidate geometry; one display frame coalesces those
|
* pinning or explicit camera navigation. Callers only invalidate geometry;
|
||||||
* notifications, reads the authoritative layout, and retargets the camera.
|
* one display frame coalesces those notifications and reads the authoritative
|
||||||
|
* layout before applying either policy.
|
||||||
*/
|
*/
|
||||||
export class ThreadMotionCoordinator {
|
export class ThreadMotionCoordinator {
|
||||||
private readonly camera: ThreadMotionCamera;
|
private readonly camera: ThreadMotionCamera;
|
||||||
@ -241,8 +258,18 @@ export class ThreadMotionCoordinator {
|
|||||||
this.camera.jumpTo(top);
|
this.camera.jumpTo(top);
|
||||||
}
|
}
|
||||||
|
|
||||||
animateTo(top: number): ThreadCameraFollowResult | null {
|
/**
|
||||||
return this.camera.navigateTo(top);
|
* Explicitly navigate to the live tail while allowing authoritative layout
|
||||||
|
* frames to retarget that destination as streamed output continues to grow.
|
||||||
|
*/
|
||||||
|
navigateLatestTo(top: number): ThreadCameraFollowResult | null {
|
||||||
|
this.camera.cancel();
|
||||||
|
this.transition("navigate-latest");
|
||||||
|
const result = this.camera.navigateTo(top);
|
||||||
|
if (!result || result === "settled") {
|
||||||
|
this.settleLatestNavigation();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
navigateHistoryTo(top: number): ThreadCameraFollowResult | null {
|
navigateHistoryTo(top: number): ThreadCameraFollowResult | null {
|
||||||
@ -271,6 +298,15 @@ export class ThreadMotionCoordinator {
|
|||||||
*/
|
*/
|
||||||
observeScroll(nearBottom: boolean): ThreadScrollOwner {
|
observeScroll(nearBottom: boolean): ThreadScrollOwner {
|
||||||
switch (this.mode) {
|
switch (this.mode) {
|
||||||
|
case "navigating-latest":
|
||||||
|
if (!this.camera.isFollowing()) {
|
||||||
|
if (nearBottom) {
|
||||||
|
this.settleLatestNavigation();
|
||||||
|
} else {
|
||||||
|
this.invalidateGeometry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "navigation";
|
||||||
case "navigating-history":
|
case "navigating-history":
|
||||||
if (!this.camera.isFollowing()) {
|
if (!this.camera.isFollowing()) {
|
||||||
this.transition("navigation-settled");
|
this.transition("navigation-settled");
|
||||||
@ -307,7 +343,8 @@ export class ThreadMotionCoordinator {
|
|||||||
|
|
||||||
private isHistoryMode(): boolean {
|
private isHistoryMode(): boolean {
|
||||||
return (
|
return (
|
||||||
this.mode === "navigating-history"
|
this.mode === "navigating-latest"
|
||||||
|
|| this.mode === "navigating-history"
|
||||||
|| this.mode === "browsing-history"
|
|| this.mode === "browsing-history"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -336,15 +373,17 @@ export class ThreadMotionCoordinator {
|
|||||||
const result = this.camera.followTo(target);
|
const result = this.camera.followTo(target);
|
||||||
if (
|
if (
|
||||||
result
|
result
|
||||||
&& (
|
&& Math.abs(target - geometry.scrollTop) > GEOMETRY_EPSILON_PX
|
||||||
Math.abs(target - geometry.scrollTop) > GEOMETRY_EPSILON_PX
|
|
||||||
|| result === "retargeted"
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
this.onAutoFollow?.();
|
this.onAutoFollow?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private settleLatestNavigation(): void {
|
||||||
|
if (!this.transition("navigation-settled")) return;
|
||||||
|
this.invalidateGeometry();
|
||||||
|
}
|
||||||
|
|
||||||
private readonly flushGeometry = (): void => {
|
private readonly flushGeometry = (): void => {
|
||||||
this.measurementFrameId = null;
|
this.measurementFrameId = null;
|
||||||
if (!this.geometryDirty) return;
|
if (!this.geometryDirty) return;
|
||||||
@ -358,6 +397,13 @@ export class ThreadMotionCoordinator {
|
|||||||
if (!geometry) return;
|
if (!geometry) return;
|
||||||
this.onGeometry?.(geometry);
|
this.onGeometry?.(geometry);
|
||||||
|
|
||||||
|
if (this.mode === "navigating-latest") {
|
||||||
|
const result = this.camera.navigateTo(geometry.maxScrollTop);
|
||||||
|
if (!result || result === "settled") {
|
||||||
|
this.settleLatestNavigation();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.mode === "follow-completion") {
|
if (this.mode === "follow-completion") {
|
||||||
this.followGeometry(geometry);
|
this.followGeometry(geometry);
|
||||||
return;
|
return;
|
||||||
@ -374,8 +420,9 @@ export class ThreadMotionCoordinator {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Before output exists, the real lower scroll boundary is the only
|
// Before output exists, the real lower scroll boundary is the only
|
||||||
// position with zero hidden downward travel. Once output exists, start
|
// position with zero hidden downward travel. Once output exists, first
|
||||||
// from the prompt origin and let the follow camera reveal its growth.
|
// establish the prompt origin; automatic follow below then resolves the
|
||||||
|
// current tail in this same authoritative geometry frame.
|
||||||
this.camera.jumpTo(
|
this.camera.jumpTo(
|
||||||
this.turn.hasOutput ? geometry.promptTop : geometry.maxScrollTop,
|
this.turn.hasOutput ? geometry.promptTop : geometry.maxScrollTop,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -39,74 +39,57 @@ function cameraHarness(prefersReducedMotion = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("ThreadCameraController", () => {
|
describe("ThreadCameraController", () => {
|
||||||
it("responds immediately, then eases out as a static target gets closer", () => {
|
it("pins automatic follow in the geometry frame without camera debt", () => {
|
||||||
const { camera, viewport, advance } = cameraHarness();
|
const { camera, viewport, frames } = cameraHarness();
|
||||||
|
|
||||||
camera.followTo(60);
|
expect(camera.followTo(60)).toBe("settled");
|
||||||
advance(16);
|
|
||||||
const firstStep = viewport.scrollTop;
|
|
||||||
advance(16);
|
|
||||||
const secondStep = viewport.scrollTop - firstStep;
|
|
||||||
advance(16);
|
|
||||||
const thirdStep = viewport.scrollTop - firstStep - secondStep;
|
|
||||||
|
|
||||||
expect(firstStep).toBeGreaterThan(0);
|
expect(viewport.scrollTop).toBe(60);
|
||||||
expect(secondStep).toBeGreaterThan(0);
|
expect(frames).toHaveLength(0);
|
||||||
expect(thirdStep).toBeGreaterThan(0);
|
expect(camera.isFollowing()).toBe(false);
|
||||||
expect(secondStep).toBeLessThan(firstStep);
|
|
||||||
expect(thirdStep).toBeLessThan(secondStep);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retargets an active follow without adding another loop", () => {
|
it("retargets active history navigation without adding another loop", () => {
|
||||||
const { camera, viewport, frames, advance } = cameraHarness();
|
const { camera, viewport, frames, advance } = cameraHarness();
|
||||||
|
|
||||||
expect(camera.followTo(100)).toBe("started");
|
expect(camera.navigateTo(100)).toBe("started");
|
||||||
expect(frames).toHaveLength(1);
|
expect(frames).toHaveLength(1);
|
||||||
advance(16);
|
advance(16);
|
||||||
expect(frames).toHaveLength(1);
|
expect(frames).toHaveLength(1);
|
||||||
|
|
||||||
expect(camera.followTo(180)).toBe("retargeted");
|
expect(camera.navigateTo(180)).toBe("retargeted");
|
||||||
expect(frames).toHaveLength(1);
|
expect(frames).toHaveLength(1);
|
||||||
for (let frame = 0; frame < 120; frame += 1) advance(16);
|
for (let frame = 0; frame < 120; frame += 1) advance(16);
|
||||||
expect(viewport.scrollTop).toBe(180);
|
expect(viewport.scrollTop).toBe(180);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tracks repeated target growth as one monotonic camera movement", () => {
|
it("pins repeated automatic targets without accumulating lag", () => {
|
||||||
const { camera, viewport, advance } = cameraHarness();
|
const { camera, viewport, frames } = cameraHarness();
|
||||||
|
|
||||||
camera.followTo(80);
|
camera.followTo(80);
|
||||||
advance(16);
|
expect(viewport.scrollTop).toBe(80);
|
||||||
const first = viewport.scrollTop;
|
|
||||||
camera.followTo(140);
|
camera.followTo(140);
|
||||||
advance(16);
|
expect(viewport.scrollTop).toBe(140);
|
||||||
const second = viewport.scrollTop;
|
|
||||||
camera.followTo(220);
|
camera.followTo(220);
|
||||||
advance(16);
|
expect(viewport.scrollTop).toBe(220);
|
||||||
const third = viewport.scrollTop;
|
expect(frames).toHaveLength(0);
|
||||||
|
expect(camera.isFollowing()).toBe(false);
|
||||||
expect(first).toBeGreaterThan(0);
|
|
||||||
expect(second).toBeGreaterThan(first);
|
|
||||||
expect(third).toBeGreaterThan(second);
|
|
||||||
expect(camera.isFollowing()).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses a faster motion profile for explicit long-distance navigation", () => {
|
it("eases explicit long-distance navigation across frames", () => {
|
||||||
const follow = cameraHarness();
|
const { camera, viewport, advance } = cameraHarness();
|
||||||
const navigation = cameraHarness();
|
|
||||||
|
|
||||||
follow.camera.followTo(1_000);
|
camera.navigateTo(1_000);
|
||||||
navigation.camera.navigateTo(1_000);
|
advance(16);
|
||||||
follow.advance(16);
|
|
||||||
navigation.advance(16);
|
|
||||||
|
|
||||||
expect(navigation.viewport.scrollTop).toBeGreaterThan(follow.viewport.scrollTop);
|
expect(viewport.scrollTop).toBeGreaterThan(0);
|
||||||
expect(navigation.viewport.scrollTop).toBeLessThan(1_000);
|
expect(viewport.scrollTop).toBeLessThan(1_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives an immediate jump command priority over an active follow", () => {
|
it("gives an immediate jump command priority over an active follow", () => {
|
||||||
const { camera, viewport, scheduler, frames } = cameraHarness();
|
const { camera, viewport, scheduler, frames } = cameraHarness();
|
||||||
|
|
||||||
camera.followTo(240);
|
camera.navigateTo(240);
|
||||||
expect(frames).toHaveLength(1);
|
expect(frames).toHaveLength(1);
|
||||||
camera.jumpTo(40);
|
camera.jumpTo(40);
|
||||||
|
|
||||||
@ -116,12 +99,12 @@ describe("ThreadCameraController", () => {
|
|||||||
expect(frames).toHaveLength(0);
|
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 regular = cameraHarness();
|
||||||
const reduced = cameraHarness(true);
|
const reduced = cameraHarness(true);
|
||||||
|
|
||||||
expect(regular.camera.followTo(240)).toBe("started");
|
expect(regular.camera.navigateTo(240)).toBe("started");
|
||||||
expect(reduced.camera.followTo(240)).toBe("started");
|
expect(reduced.camera.navigateTo(240)).toBe("started");
|
||||||
|
|
||||||
regular.advance(16);
|
regular.advance(16);
|
||||||
reduced.advance(16);
|
reduced.advance(16);
|
||||||
|
|||||||
@ -36,7 +36,7 @@ function motionHarness(initial?: Partial<ThreadMotionGeometry>) {
|
|||||||
}),
|
}),
|
||||||
dispose: vi.fn(),
|
dispose: vi.fn(),
|
||||||
jumpTo: vi.fn(),
|
jumpTo: vi.fn(),
|
||||||
followTo: vi.fn(() => "started" as const),
|
followTo: vi.fn(() => "settled" as const),
|
||||||
isFollowing: vi.fn(() => cameraFollowing),
|
isFollowing: vi.fn(() => cameraFollowing),
|
||||||
navigateTo: vi.fn(() => {
|
navigateTo: vi.fn(() => {
|
||||||
cameraFollowing = true;
|
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 {
|
const {
|
||||||
camera,
|
camera,
|
||||||
coordinator,
|
coordinator,
|
||||||
@ -465,6 +465,83 @@ describe("ThreadMotionCoordinator", () => {
|
|||||||
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
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", () => {
|
it("pins a waiting prompt to the exact lower boundary across all layout changes", () => {
|
||||||
const {
|
const {
|
||||||
camera,
|
camera,
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
windowMessages,
|
windowMessages,
|
||||||
} from "@/components/thread/ThreadViewport";
|
} from "@/components/thread/ThreadViewport";
|
||||||
import { ThreadCameraController } from "@/components/thread/thread-camera";
|
import { ThreadCameraController } from "@/components/thread/thread-camera";
|
||||||
|
import { ThreadMotionCoordinator } from "@/components/thread/thread-motion";
|
||||||
import type { UIMessage } from "@/lib/types";
|
import type { UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
const messages: UIMessage[] = [
|
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 () => {
|
it("pins the waiting boundary across composer and grid-track growth", async () => {
|
||||||
const resizeObserver = stubResizeObserver();
|
const resizeObserver = stubResizeObserver();
|
||||||
const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo");
|
const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo");
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user