| 1 | import type { ChatMountedOrder } from "./chatMountedOrder"; |
| 2 | import type { ChatScrollController } from "./chatScrollController"; |
| 3 | import type { NavigateToTurn, TurnNavigationTarget } from "./historyTurnNavigation"; |
| 4 | |
| 5 | export type TurnJumpReason = "turnUnavailable" | "snapshotExpired" | "mountTimeout"; |
| 6 | export type TurnJumpState = { turn: string | null; status: "idle" | "loading" | "failed"; reason?: TurnJumpReason }; |
| 7 | export type TurnJumpTarget = { turn: string; resolve: () => Promise<TurnNavigationTarget | undefined> }; |
| 8 | export interface TurnJumpDeps { |
| 9 | mounts: ChatMountedOrder; scroll: ChatScrollController; |
| 10 | resolveKey: (target: TurnNavigationTarget) => string | undefined; |
| 11 | navigate: NavigateToTurn; isCurrent: () => boolean; |
| 12 | refresh: () => Promise<void>; |
| 13 | clock?: { setTimeout: typeof setTimeout; clearTimeout: typeof clearTimeout }; |
| 14 | } |
| 15 | const IDLE: TurnJumpState = Object.freeze({ turn: null, status: "idle" }); |
| 16 | |
| 17 | /** One intent owns metadata resolution, window replacement and DOM positioning. */ |
| 18 | export class ChatTurnJump { |
| 19 | private listeners = new Set<() => void>(); |
| 20 | private state = IDLE; |
| 21 | private interaction = 0; |
| 22 | private releaseReader?: () => void; |
| 23 | private cancelMount?: () => void; |
| 24 | private retryTarget?: TurnJumpTarget; |
| 25 | constructor(private deps: TurnJumpDeps) {} |
| 26 | getSnapshot = () => this.state; |
| 27 | subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }; |
| 28 | private publish(state: TurnJumpState) { this.state = state; for (const listener of [...this.listeners]) listener(); } |
| 29 | cancel(): void { this.interaction++; this.releaseReader?.(); this.cancelMount?.(); this.publish(IDLE); } |
| 30 | dispose(): void { this.cancel(); this.listeners.clear(); } |
| 31 | retry(): Promise<void> { return this.retryTarget ? this.jump(this.retryTarget) : Promise.resolve(); } |
| 32 | jumpTo(key: string): void { this.cancel(); this.deps.scroll.stopFollowing(); this.deps.scroll.jump(key); } |
| 33 | async jump(target: TurnJumpTarget): Promise<void> { |
| 34 | this.cancel(); |
| 35 | const interaction = this.interaction; |
| 36 | const current = () => interaction === this.interaction && this.deps.isCurrent(); |
| 37 | this.retryTarget = target; |
| 38 | this.deps.scroll.stopFollowing(); |
| 39 | this.releaseReader = this.deps.scroll.subscribeReaderIntent(() => this.cancel()); |
| 40 | this.publish({ turn: target.turn, status: "loading" }); |
| 41 | try { |
| 42 | let resolved = await target.resolve(); |
| 43 | if (!current()) return; |
| 44 | if (!resolved) throw new Error("turnUnavailable"); |
| 45 | // A retry keeps the selected message even if ordinals changed meanwhile. |
| 46 | const original = resolved; |
| 47 | this.retryTarget = { turn: target.turn, resolve: async () => original }; |
| 48 | let key = this.deps.resolveKey(resolved); |
| 49 | if (!key) { |
| 50 | let outcome = await this.deps.navigate(resolved, current); |
| 51 | if (!current()) return; |
| 52 | if (outcome === "stale") { |
| 53 | // Preserve identity across a rewrite; never reinterpret the ordinal. |
| 54 | await this.deps.refresh(); |
| 55 | if (!current()) return; |
| 56 | resolved = { messageId: resolved.messageId }; |
| 57 | outcome = await this.deps.navigate(resolved, current); |
| 58 | } |
| 59 | if (!current()) return; |
| 60 | if (outcome === "cancelled") { this.cancel(); return; } |
| 61 | if (outcome !== "loaded") throw new Error(outcome === "stale" ? "snapshotExpired" : "turnUnavailable"); |
| 62 | key = await this.waitForMount(resolved, current); |
| 63 | } |
| 64 | if (!current()) return; |
| 65 | if (!key) throw new Error("mountTimeout"); |
| 66 | this.deps.scroll.jump(key); |
| 67 | this.releaseReader?.(); this.releaseReader = undefined; |
| 68 | this.publish(IDLE); |
| 69 | } catch (error) { |
| 70 | if (!current()) return; |
| 71 | this.releaseReader?.(); this.releaseReader = undefined; |
| 72 | const message = error instanceof Error ? error.message : "turnUnavailable"; |
| 73 | this.publish({ turn: target.turn, status: "failed", reason: message === "snapshotExpired" || message === "mountTimeout" ? message : "turnUnavailable" }); |
| 74 | } |
| 75 | } |
| 76 | private waitForMount(target: TurnNavigationTarget, current: () => boolean): Promise<string | undefined> { |
| 77 | const found = this.deps.resolveKey(target); |
| 78 | if (found) return Promise.resolve(found); |
| 79 | return new Promise(resolve => { |
| 80 | const clock = this.deps.clock ?? { setTimeout, clearTimeout }; |
| 81 | let off = () => {}; |
| 82 | const finish = (key?: string) => { off(); clock.clearTimeout(timer); this.cancelMount = undefined; resolve(key); }; |
| 83 | const timer = clock.setTimeout(() => finish(), 5000); |
| 84 | this.cancelMount = () => finish(); |
| 85 | off = this.deps.mounts.subscribe(() => { |
| 86 | if (!current()) { finish(); return; } |
| 87 | const key = this.deps.resolveKey(target); if (key) finish(key); |
| 88 | }); |
| 89 | }); |
| 90 | } |
| 91 | } |
| 92 |