383 lines
11 KiB
TypeScript
383 lines
11 KiB
TypeScript
import type {
|
|||
|
|
ThreadCameraController,
|
||
|
|
ThreadCameraFollowResult,
|
||
|
|
} from "@/components/thread/thread-camera";
|
||
|
|
|
||
|
|
type ThreadMotionMode =
|
||
|
|
| "idle"
|
||
|
|
| "anchor-prompt"
|
||
|
|
| "follow-output"
|
||
|
|
| "follow-completion"
|
||
|
|
| "navigating-history"
|
||
|
|
| "browsing-history";
|
||
|
|
|
||
|
|
type AutomaticThreadMotionMode =
|
||
|
|
| "idle"
|
||
|
|
| "anchor-prompt"
|
||
|
|
| "follow-output";
|
||
|
|
|
||
|
|
type ThreadMotionEvent =
|
||
|
|
| "navigate-history"
|
||
|
|
| "navigation-settled"
|
||
|
|
| "user-scroll"
|
||
|
|
| "boundary-scroll"
|
||
|
|
| "turn-completed"
|
||
|
|
| "resume-follow";
|
||
|
|
|
||
|
|
type ThreadMotionTransition = ThreadMotionMode | "current-automatic-mode";
|
||
|
|
|
||
|
|
const THREAD_MOTION_TRANSITIONS: Readonly<
|
||
|
|
Record<
|
||
|
|
ThreadMotionMode,
|
||
|
|
Readonly<Partial<Record<ThreadMotionEvent, ThreadMotionTransition>>>
|
||
|
|
>
|
||
|
|
> = {
|
||
|
|
idle: {
|
||
|
|
"navigate-history": "navigating-history",
|
||
|
|
"user-scroll": "browsing-history",
|
||
|
|
},
|
||
|
|
"anchor-prompt": {
|
||
|
|
"navigate-history": "navigating-history",
|
||
|
|
"user-scroll": "browsing-history",
|
||
|
|
"turn-completed": "follow-completion",
|
||
|
|
},
|
||
|
|
"follow-output": {
|
||
|
|
"navigate-history": "navigating-history",
|
||
|
|
"user-scroll": "browsing-history",
|
||
|
|
"turn-completed": "follow-completion",
|
||
|
|
},
|
||
|
|
"follow-completion": {
|
||
|
|
"navigate-history": "navigating-history",
|
||
|
|
"user-scroll": "browsing-history",
|
||
|
|
},
|
||
|
|
"navigating-history": {
|
||
|
|
"navigate-history": "navigating-history",
|
||
|
|
"navigation-settled": "browsing-history",
|
||
|
|
"user-scroll": "browsing-history",
|
||
|
|
"boundary-scroll": "browsing-history",
|
||
|
|
"resume-follow": "current-automatic-mode",
|
||
|
|
},
|
||
|
|
"browsing-history": {
|
||
|
|
"navigate-history": "navigating-history",
|
||
|
|
"resume-follow": "current-automatic-mode",
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
export interface ThreadMotionGeometry {
|
||
|
|
scrollTop: number;
|
||
|
|
scrollHeight: number;
|
||
|
|
clientHeight: number;
|
||
|
|
maxScrollTop: number;
|
||
|
|
composerHeight: number;
|
||
|
|
promptTop: number | null;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ThreadMotionTurn {
|
||
|
|
id: string | null;
|
||
|
|
promptId: string | null;
|
||
|
|
hasOutput: boolean;
|
||
|
|
entry?: "submitted" | "restored";
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ThreadMotionSnapshot {
|
||
|
|
mode: ThreadMotionMode;
|
||
|
|
turnId: string | null;
|
||
|
|
promptId: string | null;
|
||
|
|
promptPositioned: boolean;
|
||
|
|
measurementPending: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface ThreadMotionScheduler {
|
||
|
|
request: (callback: FrameRequestCallback) => number;
|
||
|
|
cancel: (id: number) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
type ThreadMotionCamera = Pick<
|
||
|
|
ThreadCameraController,
|
||
|
|
| "cancel"
|
||
|
|
| "dispose"
|
||
|
|
| "followTo"
|
||
|
|
| "isFollowing"
|
||
|
|
| "jumpTo"
|
||
|
|
| "navigateTo"
|
||
|
|
>;
|
||
|
|
|
||
|
|
interface ThreadMotionCoordinatorOptions {
|
||
|
|
camera: ThreadMotionCamera;
|
||
|
|
measure: (promptId: string | null) => ThreadMotionGeometry | null;
|
||
|
|
onGeometry?: (geometry: ThreadMotionGeometry) => void;
|
||
|
|
onAutoFollow?: () => void;
|
||
|
|
scheduler?: ThreadMotionScheduler;
|
||
|
|
}
|
||
|
|
|
||
|
|
const GEOMETRY_EPSILON_PX = 0.5;
|
||
|
|
|
||
|
|
type ThreadScrollOwner = "automatic" | "navigation" | "user";
|
||
|
|
|
||
|
|
function defaultScheduler(): ThreadMotionScheduler {
|
||
|
|
return {
|
||
|
|
request: (callback) => window.requestAnimationFrame(callback),
|
||
|
|
cancel: (id) => window.cancelAnimationFrame(id),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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.
|
||
|
|
*/
|
||
|
|
export class ThreadMotionCoordinator {
|
||
|
|
private readonly camera: ThreadMotionCamera;
|
||
|
|
private readonly measure: ThreadMotionCoordinatorOptions["measure"];
|
||
|
|
private readonly onGeometry?: ThreadMotionCoordinatorOptions["onGeometry"];
|
||
|
|
private readonly onAutoFollow?: ThreadMotionCoordinatorOptions["onAutoFollow"];
|
||
|
|
private readonly scheduler: ThreadMotionScheduler;
|
||
|
|
private turn: ThreadMotionTurn = {
|
||
|
|
id: null,
|
||
|
|
promptId: null,
|
||
|
|
hasOutput: false,
|
||
|
|
};
|
||
|
|
private mode: ThreadMotionMode = "idle";
|
||
|
|
private promptPositioned = false;
|
||
|
|
private measurementFrameId: number | null = null;
|
||
|
|
private geometryDirty = false;
|
||
|
|
|
||
|
|
constructor(options: ThreadMotionCoordinatorOptions) {
|
||
|
|
this.camera = options.camera;
|
||
|
|
this.measure = options.measure;
|
||
|
|
this.onGeometry = options.onGeometry;
|
||
|
|
this.onAutoFollow = options.onAutoFollow;
|
||
|
|
this.scheduler = options.scheduler ?? defaultScheduler();
|
||
|
|
}
|
||
|
|
|
||
|
|
snapshot(): ThreadMotionSnapshot {
|
||
|
|
return {
|
||
|
|
mode: this.mode,
|
||
|
|
turnId: this.turn.id,
|
||
|
|
promptId: this.turn.promptId,
|
||
|
|
promptPositioned: this.promptPositioned,
|
||
|
|
measurementPending: this.measurementFrameId !== null,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
updateTurn(turn: ThreadMotionTurn): void {
|
||
|
|
if (!turn.id) {
|
||
|
|
this.completeTurn();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const isNewTurn = this.turn.id !== turn.id;
|
||
|
|
this.turn = turn;
|
||
|
|
if (isNewTurn) {
|
||
|
|
this.camera.cancel();
|
||
|
|
this.promptPositioned = turn.entry === "restored";
|
||
|
|
this.mode = this.promptPositioned && turn.hasOutput
|
||
|
|
? "follow-output"
|
||
|
|
: "anchor-prompt";
|
||
|
|
} else if (!this.isHistoryMode() && this.promptPositioned) {
|
||
|
|
this.mode = turn.hasOutput ? "follow-output" : "anchor-prompt";
|
||
|
|
}
|
||
|
|
this.invalidateGeometry();
|
||
|
|
}
|
||
|
|
|
||
|
|
completeTurn(): void {
|
||
|
|
if (!this.turn.id) {
|
||
|
|
this.invalidateGeometry();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
// 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.
|
||
|
|
this.transition("turn-completed");
|
||
|
|
this.turn = { id: null, promptId: null, hasOutput: false };
|
||
|
|
this.promptPositioned = false;
|
||
|
|
this.invalidateGeometry();
|
||
|
|
}
|
||
|
|
|
||
|
|
invalidateGeometry(): void {
|
||
|
|
this.geometryDirty = true;
|
||
|
|
if (this.measurementFrameId !== null) return;
|
||
|
|
this.measurementFrameId = this.scheduler.request(this.flushGeometry);
|
||
|
|
}
|
||
|
|
|
||
|
|
takeUserControl(): void {
|
||
|
|
this.handleUserScrollIntent(true);
|
||
|
|
}
|
||
|
|
|
||
|
|
handleUserScrollIntent(canScroll: boolean): void {
|
||
|
|
const event = canScroll ? "user-scroll" : "boundary-scroll";
|
||
|
|
if (!this.transition(event)) return;
|
||
|
|
this.camera.cancel();
|
||
|
|
}
|
||
|
|
|
||
|
|
resumeAutoFollow(): void {
|
||
|
|
if (!this.transition("resume-follow")) return;
|
||
|
|
this.camera.cancel();
|
||
|
|
this.invalidateGeometry();
|
||
|
|
}
|
||
|
|
|
||
|
|
jumpTo(top: number): void {
|
||
|
|
this.camera.jumpTo(top);
|
||
|
|
}
|
||
|
|
|
||
|
|
animateTo(top: number): ThreadCameraFollowResult | null {
|
||
|
|
return this.camera.navigateTo(top);
|
||
|
|
}
|
||
|
|
|
||
|
|
navigateHistoryTo(top: number): ThreadCameraFollowResult | null {
|
||
|
|
this.camera.cancel();
|
||
|
|
this.transition("navigate-history");
|
||
|
|
const result = this.camera.navigateTo(top);
|
||
|
|
if (!result || result === "settled") {
|
||
|
|
this.transition("navigation-settled");
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
isAutoFollowPaused(): boolean {
|
||
|
|
return this.isHistoryMode();
|
||
|
|
}
|
||
|
|
|
||
|
|
isBrowsingHistory(): boolean {
|
||
|
|
return this.mode === "browsing-history";
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A scroll event reports geometry; it does not prove user intent. Explicit
|
||
|
|
* input handlers call takeUserControl() before the browser scrolls. Layout,
|
||
|
|
* sticky positioning, and camera writes therefore remain automatic even
|
||
|
|
* when the browser emits an intermediate scroll event for them.
|
||
|
|
*/
|
||
|
|
observeScroll(nearBottom: boolean): ThreadScrollOwner {
|
||
|
|
switch (this.mode) {
|
||
|
|
case "navigating-history":
|
||
|
|
if (!this.camera.isFollowing()) {
|
||
|
|
this.transition("navigation-settled");
|
||
|
|
if (nearBottom) this.resumeAutoFollow();
|
||
|
|
}
|
||
|
|
return "navigation";
|
||
|
|
case "browsing-history":
|
||
|
|
if (!nearBottom) return "user";
|
||
|
|
this.resumeAutoFollow();
|
||
|
|
return "automatic";
|
||
|
|
default:
|
||
|
|
if (!nearBottom) this.invalidateGeometry();
|
||
|
|
return "automatic";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
reset(): void {
|
||
|
|
if (this.measurementFrameId !== null) {
|
||
|
|
this.scheduler.cancel(this.measurementFrameId);
|
||
|
|
this.measurementFrameId = null;
|
||
|
|
}
|
||
|
|
this.geometryDirty = false;
|
||
|
|
this.camera.cancel();
|
||
|
|
this.turn = { id: null, promptId: null, hasOutput: false };
|
||
|
|
this.mode = "idle";
|
||
|
|
this.promptPositioned = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
dispose(): void {
|
||
|
|
this.reset();
|
||
|
|
this.camera.dispose();
|
||
|
|
}
|
||
|
|
|
||
|
|
private isHistoryMode(): boolean {
|
||
|
|
return (
|
||
|
|
this.mode === "navigating-history"
|
||
|
|
|| this.mode === "browsing-history"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
private automaticMode(): AutomaticThreadMotionMode {
|
||
|
|
if (!this.turn.id) return "idle";
|
||
|
|
return this.promptPositioned && this.turn.hasOutput
|
||
|
|
? "follow-output"
|
||
|
|
: "anchor-prompt";
|
||
|
|
}
|
||
|
|
|
||
|
|
private transition(event: ThreadMotionEvent): boolean {
|
||
|
|
const transition = THREAD_MOTION_TRANSITIONS[this.mode][event];
|
||
|
|
if (!transition) return false;
|
||
|
|
const nextMode =
|
||
|
|
transition === "current-automatic-mode"
|
||
|
|
? this.automaticMode()
|
||
|
|
: transition;
|
||
|
|
if (nextMode === this.mode) return false;
|
||
|
|
this.mode = nextMode;
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
private followGeometry(geometry: ThreadMotionGeometry): void {
|
||
|
|
const target = geometry.maxScrollTop;
|
||
|
|
const result = this.camera.followTo(target);
|
||
|
|
if (
|
||
|
|
result
|
||
|
|
&& (
|
||
|
|
Math.abs(target - geometry.scrollTop) > GEOMETRY_EPSILON_PX
|
||
|
|
|| result === "retargeted"
|
||
|
|
)
|
||
|
|
) {
|
||
|
|
this.onAutoFollow?.();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private readonly flushGeometry = (): void => {
|
||
|
|
this.measurementFrameId = null;
|
||
|
|
if (!this.geometryDirty) return;
|
||
|
|
this.geometryDirty = false;
|
||
|
|
|
||
|
|
const needsPromptGeometry =
|
||
|
|
!this.isHistoryMode()
|
||
|
|
&& this.turn.id !== null
|
||
|
|
&& !this.promptPositioned;
|
||
|
|
const geometry = this.measure(needsPromptGeometry ? this.turn.promptId : null);
|
||
|
|
if (!geometry) return;
|
||
|
|
this.onGeometry?.(geometry);
|
||
|
|
|
||
|
|
if (this.mode === "follow-completion") {
|
||
|
|
this.followGeometry(geometry);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (this.isHistoryMode() || !this.turn.id) return;
|
||
|
|
if (!this.turn.promptId && this.turn.entry !== "restored") {
|
||
|
|
this.mode = "anchor-prompt";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!this.promptPositioned) {
|
||
|
|
if (geometry.promptTop === null) {
|
||
|
|
this.mode = "anchor-prompt";
|
||
|
|
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.
|
||
|
|
this.camera.jumpTo(
|
||
|
|
this.turn.hasOutput ? geometry.promptTop : geometry.maxScrollTop,
|
||
|
|
);
|
||
|
|
this.promptPositioned = true;
|
||
|
|
} else if (
|
||
|
|
!this.turn.hasOutput
|
||
|
|
&& Math.abs(geometry.maxScrollTop - geometry.scrollTop)
|
||
|
|
> GEOMETRY_EPSILON_PX
|
||
|
|
) {
|
||
|
|
// Hero docking, the run drawer, fonts, and responsive chrome can all
|
||
|
|
// change document height while the model is still silent. Every
|
||
|
|
// authoritative geometry frame reasserts the real lower boundary so no
|
||
|
|
// stale downward scroll pocket survives a layout transition.
|
||
|
|
this.camera.jumpTo(geometry.maxScrollTop);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!this.turn.hasOutput) {
|
||
|
|
this.mode = "anchor-prompt";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.mode = "follow-output";
|
||
|
|
this.followGeometry(geometry);
|
||
|
|
};
|
||
|
|
}
|