Files
nanobot/webui/src/components/thread/thread-motion.ts
T

383 lines
11 KiB
TypeScript
Raw Normal View History

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);
};
}