fix(webui): keep streaming tail visible (#5140)
This commit is contained in:
@@ -275,7 +275,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
if (el) {
|
||||
const top = Math.max(0, el.scrollHeight - el.clientHeight);
|
||||
if (smooth) {
|
||||
threadMotionRef.current?.animateTo(top);
|
||||
threadMotionRef.current?.navigateLatestTo(top);
|
||||
} else {
|
||||
threadMotionRef.current?.jumpTo(top);
|
||||
}
|
||||
@@ -289,7 +289,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
(smooth = false, options?: { force?: boolean }) => {
|
||||
const force = options?.force ?? false;
|
||||
if (!force && threadMotionRef.current?.isAutoFollowPaused()) return;
|
||||
threadMotionRef.current?.resumeAutoFollow();
|
||||
if (!smooth) threadMotionRef.current?.resumeAutoFollow();
|
||||
scrollToBottomNow(smooth);
|
||||
},
|
||||
[scrollToBottomNow],
|
||||
|
||||
@@ -4,7 +4,7 @@ interface ThreadCameraMotionProfile {
|
||||
* camera closes roughly 95% of an uncapped distance in three time constants.
|
||||
*/
|
||||
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;
|
||||
/** Avoids spending frames chasing sub-pixel layout noise. */
|
||||
settleDistancePx: number;
|
||||
@@ -12,13 +12,6 @@ interface ThreadCameraMotionProfile {
|
||||
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> = {
|
||||
responseTimeMs: 110,
|
||||
maxSpeedPxPerSecond: 12_000,
|
||||
@@ -26,18 +19,6 @@ const THREAD_CAMERA_NAVIGATION_MOTION: Readonly<ThreadCameraMotionProfile> = {
|
||||
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> = {
|
||||
responseTimeMs: 45,
|
||||
maxSpeedPxPerSecond: 24_000,
|
||||
@@ -63,12 +44,10 @@ interface ThreadCameraOptions {
|
||||
prefersReducedMotion?: () => boolean;
|
||||
}
|
||||
|
||||
type ThreadCameraMotionKind = "follow" | "navigation";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* frame loop.
|
||||
* move during explicit history navigation without restarting a duration or
|
||||
* adding another frame loop.
|
||||
*/
|
||||
function easeOutChase(
|
||||
current: number,
|
||||
@@ -108,7 +87,6 @@ export class ThreadCameraController {
|
||||
private phase: "idle" | "following" = "idle";
|
||||
private target = 0;
|
||||
private lastTimestamp: number | null = null;
|
||||
private motionKind: ThreadCameraMotionKind = "follow";
|
||||
|
||||
constructor(
|
||||
getViewport: () => ThreadCameraViewport | null,
|
||||
@@ -131,25 +109,31 @@ export class ThreadCameraController {
|
||||
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 {
|
||||
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 {
|
||||
return this.moveTo(top, "navigation");
|
||||
return this.moveTo(top);
|
||||
}
|
||||
|
||||
private moveTo(
|
||||
top: number,
|
||||
motionKind: ThreadCameraMotionKind,
|
||||
): ThreadCameraFollowResult | null {
|
||||
private moveTo(top: number): ThreadCameraFollowResult | null {
|
||||
const viewport = this.getViewport();
|
||||
if (!viewport) return null;
|
||||
const current = viewport.scrollTop;
|
||||
this.target = Math.max(0, top);
|
||||
this.motionKind = motionKind;
|
||||
|
||||
const motion = this.currentMotion(motionKind);
|
||||
const motion = this.currentMotion();
|
||||
if (this.phase === "following") {
|
||||
return "retargeted";
|
||||
}
|
||||
@@ -171,7 +155,6 @@ export class ThreadCameraController {
|
||||
}
|
||||
this.phase = "idle";
|
||||
this.lastTimestamp = null;
|
||||
this.motionKind = "follow";
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
@@ -186,7 +169,7 @@ export class ThreadCameraController {
|
||||
return;
|
||||
}
|
||||
|
||||
const motion = this.currentMotion(this.motionKind);
|
||||
const motion = this.currentMotion();
|
||||
const previousTimestamp = this.lastTimestamp ?? timestamp - (1000 / 60);
|
||||
const deltaMs = Math.min(
|
||||
motion.maxFrameDeltaMs,
|
||||
@@ -220,15 +203,10 @@ export class ThreadCameraController {
|
||||
this.frameId = this.scheduler.request(this.advance);
|
||||
};
|
||||
|
||||
private currentMotion(kind: ThreadCameraMotionKind): ThreadCameraMotionProfile {
|
||||
if (kind === "navigation") {
|
||||
return this.prefersReducedMotion()
|
||||
? THREAD_CAMERA_REDUCED_NAVIGATION_MOTION
|
||||
: THREAD_CAMERA_NAVIGATION_MOTION;
|
||||
}
|
||||
private currentMotion(): ThreadCameraMotionProfile {
|
||||
return this.prefersReducedMotion()
|
||||
? THREAD_CAMERA_REDUCED_MOTION
|
||||
: THREAD_CAMERA_FOLLOW_MOTION;
|
||||
? THREAD_CAMERA_REDUCED_NAVIGATION_MOTION
|
||||
: THREAD_CAMERA_NAVIGATION_MOTION;
|
||||
}
|
||||
|
||||
private write(viewport: ThreadCameraViewport, top: number): void {
|
||||
|
||||
@@ -8,6 +8,7 @@ type ThreadMotionMode =
|
||||
| "anchor-prompt"
|
||||
| "follow-output"
|
||||
| "follow-completion"
|
||||
| "navigating-latest"
|
||||
| "navigating-history"
|
||||
| "browsing-history";
|
||||
|
||||
@@ -17,6 +18,7 @@ type AutomaticThreadMotionMode =
|
||||
| "follow-output";
|
||||
|
||||
type ThreadMotionEvent =
|
||||
| "navigate-latest"
|
||||
| "navigate-history"
|
||||
| "navigation-settled"
|
||||
| "user-scroll"
|
||||
@@ -34,25 +36,38 @@ const THREAD_MOTION_TRANSITIONS: Readonly<
|
||||
>
|
||||
> = {
|
||||
idle: {
|
||||
"navigate-latest": "navigating-latest",
|
||||
"navigate-history": "navigating-history",
|
||||
"user-scroll": "browsing-history",
|
||||
},
|
||||
"anchor-prompt": {
|
||||
"navigate-latest": "navigating-latest",
|
||||
"navigate-history": "navigating-history",
|
||||
"user-scroll": "browsing-history",
|
||||
"turn-completed": "follow-completion",
|
||||
},
|
||||
"follow-output": {
|
||||
"navigate-latest": "navigating-latest",
|
||||
"navigate-history": "navigating-history",
|
||||
"user-scroll": "browsing-history",
|
||||
"turn-completed": "follow-completion",
|
||||
},
|
||||
"follow-completion": {
|
||||
"navigate-latest": "navigating-latest",
|
||||
"navigate-history": "navigating-history",
|
||||
"user-scroll": "browsing-history",
|
||||
"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": {
|
||||
"navigate-latest": "navigating-latest",
|
||||
"navigate-history": "navigating-history",
|
||||
"navigation-settled": "browsing-history",
|
||||
"user-scroll": "browsing-history",
|
||||
@@ -60,6 +75,7 @@ const THREAD_MOTION_TRANSITIONS: Readonly<
|
||||
"resume-follow": "current-automatic-mode",
|
||||
},
|
||||
"browsing-history": {
|
||||
"navigate-latest": "navigating-latest",
|
||||
"navigate-history": "navigating-history",
|
||||
"resume-follow": "current-automatic-mode",
|
||||
},
|
||||
@@ -124,9 +140,10 @@ function defaultScheduler(): ThreadMotionScheduler {
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the policy that turns discrete layout events into continuous camera
|
||||
* motion. Callers only invalidate geometry; one display frame coalesces those
|
||||
* notifications, reads the authoritative layout, and retargets the camera.
|
||||
* Owns the policy that turns discrete layout events into automatic tail
|
||||
* pinning or explicit camera navigation. Callers only invalidate geometry;
|
||||
* one display frame coalesces those notifications and reads the authoritative
|
||||
* layout before applying either policy.
|
||||
*/
|
||||
export class ThreadMotionCoordinator {
|
||||
private readonly camera: ThreadMotionCamera;
|
||||
@@ -241,8 +258,18 @@ export class ThreadMotionCoordinator {
|
||||
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 {
|
||||
@@ -271,6 +298,15 @@ export class ThreadMotionCoordinator {
|
||||
*/
|
||||
observeScroll(nearBottom: boolean): ThreadScrollOwner {
|
||||
switch (this.mode) {
|
||||
case "navigating-latest":
|
||||
if (!this.camera.isFollowing()) {
|
||||
if (nearBottom) {
|
||||
this.settleLatestNavigation();
|
||||
} else {
|
||||
this.invalidateGeometry();
|
||||
}
|
||||
}
|
||||
return "navigation";
|
||||
case "navigating-history":
|
||||
if (!this.camera.isFollowing()) {
|
||||
this.transition("navigation-settled");
|
||||
@@ -307,7 +343,8 @@ export class ThreadMotionCoordinator {
|
||||
|
||||
private isHistoryMode(): boolean {
|
||||
return (
|
||||
this.mode === "navigating-history"
|
||||
this.mode === "navigating-latest"
|
||||
|| this.mode === "navigating-history"
|
||||
|| this.mode === "browsing-history"
|
||||
);
|
||||
}
|
||||
@@ -336,15 +373,17 @@ export class ThreadMotionCoordinator {
|
||||
const result = this.camera.followTo(target);
|
||||
if (
|
||||
result
|
||||
&& (
|
||||
Math.abs(target - geometry.scrollTop) > GEOMETRY_EPSILON_PX
|
||||
|| result === "retargeted"
|
||||
)
|
||||
&& Math.abs(target - geometry.scrollTop) > GEOMETRY_EPSILON_PX
|
||||
) {
|
||||
this.onAutoFollow?.();
|
||||
}
|
||||
}
|
||||
|
||||
private settleLatestNavigation(): void {
|
||||
if (!this.transition("navigation-settled")) return;
|
||||
this.invalidateGeometry();
|
||||
}
|
||||
|
||||
private readonly flushGeometry = (): void => {
|
||||
this.measurementFrameId = null;
|
||||
if (!this.geometryDirty) return;
|
||||
@@ -358,6 +397,13 @@ export class ThreadMotionCoordinator {
|
||||
if (!geometry) return;
|
||||
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") {
|
||||
this.followGeometry(geometry);
|
||||
return;
|
||||
@@ -374,8 +420,9 @@ export class ThreadMotionCoordinator {
|
||||
return;
|
||||
}
|
||||
// Before output exists, the real lower scroll boundary is the only
|
||||
// position with zero hidden downward travel. Once output exists, start
|
||||
// from the prompt origin and let the follow camera reveal its growth.
|
||||
// position with zero hidden downward travel. Once output exists, first
|
||||
// establish the prompt origin; automatic follow below then resolves the
|
||||
// current tail in this same authoritative geometry frame.
|
||||
this.camera.jumpTo(
|
||||
this.turn.hasOutput ? geometry.promptTop : geometry.maxScrollTop,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user