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