| 1 | import type { ChatMountedOrder } from "./chatMountedOrder"; |
| 2 | import type { ChatScrollController } from "./chatScrollController"; |
| 3 | import type { TranscriptOutlineEntry } from "./transcriptProtocol"; |
| 4 | |
| 5 | export type TurnJumpStatus = "idle" | "loading" | "failed"; |
| 6 | |
| 7 | /** Why a turn could not be reached. Kept distinct so the rail can say which. */ |
| 8 | export type TurnJumpReason = |
| 9 | /** History is exhausted (or the budget ran out) and the node never appeared. */ |
| 10 | | "turnUnavailable" |
| 11 | /** The snapshot was recycled; the target has to be resolved against a fresh one. */ |
| 12 | | "snapshotExpired" |
| 13 | /** The jump pulled as many pages as it is allowed to. */ |
| 14 | | "pageBudgetExhausted"; |
| 15 | |
| 16 | export interface TurnJumpState { |
| 17 | /** Stable identity of the turn being located, or null when idle. */ |
| 18 | readonly turn: string | null; |
| 19 | readonly status: TurnJumpStatus; |
| 20 | readonly reason?: TurnJumpReason; |
| 21 | /** The entry a failed jump can be retried against. */ |
| 22 | readonly retry?: TranscriptOutlineEntry; |
| 23 | } |
| 24 | |
| 25 | const IDLE: TurnJumpState = Object.freeze({ turn: null, status: "idle" }); |
| 26 | |
| 27 | /** Frames to let the progressive mount advance before re-checking the target. */ |
| 28 | const MOUNT_SETTLE_FRAMES = 120; |
| 29 | /** Wall-clock ceiling for the same wait; frames stop arriving when hidden. */ |
| 30 | const MOUNT_SETTLE_MS = 2000; |
| 31 | /** How long to keep waiting for the target's node after history is exhausted. */ |
| 32 | const DRAIN_MOUNT_MS = 5000; |
| 33 | /** Pages a single jump may pull before giving up. */ |
| 34 | const MAX_JUMP_PAGES = 400; |
| 35 | |
| 36 | export interface TurnJumpDeps { |
| 37 | readonly mounts: ChatMountedOrder; |
| 38 | readonly scroll: ChatScrollController; |
| 39 | /** One older body page. `stale` means the snapshot it was paging is gone. */ |
| 40 | loadOlder: () => Promise<"loaded" | "empty" | "stale">; |
| 41 | hasOlder: () => boolean; |
| 42 | /** The DOM key of a turn once its node is mounted. */ |
| 43 | resolveKey: (entry: TranscriptOutlineEntry) => string | undefined; |
| 44 | /** Identity of the snapshot this rail is describing; a change ends the jump. */ |
| 45 | currentSnapshotId: () => string; |
| 46 | /** False once the session, tab, or snapshot this jump belongs to is gone. */ |
| 47 | isCurrent: () => boolean; |
| 48 | /** |
| 49 | * Ask the owning session to install a fresh snapshot. Only a recycled cut |
| 50 | * needs it, and only a reader-initiated retry calls it, so navigation never |
| 51 | * replaces the body on its own. |
| 52 | */ |
| 53 | refreshSnapshot: (entry: TranscriptOutlineEntry) => Promise<TranscriptOutlineEntry | undefined>; |
| 54 | /** Overrides the post-exhaustion wall-clock budget; tests shorten it. */ |
| 55 | drainMs?: number; |
| 56 | } |
| 57 | |
| 58 | /** |
| 59 | * Loads history until an unloaded turn's node is really mounted, then hands the |
| 60 | * scroll write to the shared gateway. It never assumes "the data arrived" means |
| 61 | * "the DOM exists": each page commits, the progressive mount advances, and the |
| 62 | * target is re-resolved before the viewport moves. |
| 63 | * |
| 64 | * Every navigation click goes through this one entry point, so a newer target |
| 65 | * always supersedes a pending one instead of racing it. Reader intent, an |
| 66 | * explicit cancel, a newer target, or a session/snapshot replacement all end |
| 67 | * the pending transaction; a page already in flight may finish, but it can |
| 68 | * never take scroll control back. |
| 69 | */ |
| 70 | export class ChatTurnJump { |
| 71 | private listeners = new Set<() => void>(); |
| 72 | private state: TurnJumpState = IDLE; |
| 73 | /** Interaction id; a newer target or a cancel invalidates the pending loop. */ |
| 74 | private interaction = 0; |
| 75 | private unsubscribeReader: (() => void) | undefined; |
| 76 | |
| 77 | constructor(private readonly deps: TurnJumpDeps) {} |
| 78 | |
| 79 | getSnapshot = (): TurnJumpState => this.state; |
| 80 | subscribe = (listener: () => void): (() => void) => { |
| 81 | this.listeners.add(listener); |
| 82 | return () => { this.listeners.delete(listener); }; |
| 83 | }; |
| 84 | |
| 85 | /** Reader intent observed on the transcript ends any pending jump. */ |
| 86 | private watchReader(): void { |
| 87 | this.unsubscribeReader ??= this.deps.scroll.subscribeReaderIntent(() => { this.cancel(); }); |
| 88 | } |
| 89 | |
| 90 | private detach(): void { |
| 91 | this.unsubscribeReader?.(); |
| 92 | this.unsubscribeReader = undefined; |
| 93 | } |
| 94 | |
| 95 | cancel(): void { |
| 96 | if (this.state.status === "idle") return; |
| 97 | this.interaction++; |
| 98 | this.detach(); |
| 99 | this.publish(IDLE); |
| 100 | } |
| 101 | |
| 102 | dispose(): void { |
| 103 | this.cancel(); |
| 104 | this.listeners.clear(); |
| 105 | } |
| 106 | |
| 107 | /** |
| 108 | * Re-run the jump that last failed. A recycled cut cannot be retried against |
| 109 | * itself, so the owning session installs a fresh snapshot first and the target |
| 110 | * is then re-resolved from it by its stable identity — which is what makes |
| 111 | * the retry able to succeed. The refresh is part of the transaction, so a |
| 112 | * newer click or a cancel abandons it just like a pending page loop. |
| 113 | */ |
| 114 | async retry(): Promise<void> { |
| 115 | const entry = this.state.retry; |
| 116 | const reason = this.state.reason; |
| 117 | if (entry === undefined) return; |
| 118 | const interaction = ++this.interaction; |
| 119 | if (reason === "snapshotExpired") { |
| 120 | this.detach(); |
| 121 | this.watchReader(); |
| 122 | this.publish({ turn: entry.id, status: "loading", retry: entry }); |
| 123 | let refreshed: TranscriptOutlineEntry | undefined; |
| 124 | try { |
| 125 | refreshed = await this.deps.refreshSnapshot(entry); |
| 126 | } catch { |
| 127 | if (this.interaction !== interaction || !this.deps.isCurrent()) { |
| 128 | this.bail(interaction); |
| 129 | return; |
| 130 | } |
| 131 | // Keep the same retryable failure. A transient refresh error must not |
| 132 | // fall through into paging an absent cut or consume the retry target. |
| 133 | this.fail(entry, interaction, "snapshotExpired"); |
| 134 | return; |
| 135 | } |
| 136 | if (this.interaction !== interaction || !this.deps.isCurrent()) { |
| 137 | this.bail(interaction); |
| 138 | return; |
| 139 | } |
| 140 | if (!refreshed) { |
| 141 | this.fail(entry, interaction, "turnUnavailable"); |
| 142 | return; |
| 143 | } |
| 144 | await this.jump(refreshed); |
| 145 | return; |
| 146 | } |
| 147 | await this.jump(entry); |
| 148 | } |
| 149 | |
| 150 | /** |
| 151 | * Scroll to a turn whose node is already mounted. It still goes through the |
| 152 | * transaction so it supersedes a pending jump instead of racing it: the |
| 153 | * reader's newest click must win, whatever is still paging behind it. |
| 154 | */ |
| 155 | jumpTo(key: string): void { |
| 156 | this.interaction++; |
| 157 | this.detach(); |
| 158 | this.publish(IDLE); |
| 159 | this.deps.scroll.stopFollowing(); |
| 160 | this.deps.scroll.jump(key); |
| 161 | } |
| 162 | |
| 163 | async jump(entry: TranscriptOutlineEntry): Promise<void> { |
| 164 | const interaction = ++this.interaction; |
| 165 | const snapshotId = this.deps.currentSnapshotId(); |
| 166 | const current = () => this.interaction === interaction && this.deps.isCurrent() && this.deps.currentSnapshotId() === snapshotId; |
| 167 | // Exit follow first: the reader asked for a specific turn, and a tail pin |
| 168 | // would otherwise fight the write that lands later. |
| 169 | this.deps.scroll.stopFollowing(); |
| 170 | this.detach(); |
| 171 | this.watchReader(); |
| 172 | this.publish({ turn: entry.id, status: "loading" }); |
| 173 | |
| 174 | let pages = 0; |
| 175 | try { |
| 176 | for (;;) { |
| 177 | if (!current()) { this.bail(interaction); return; } |
| 178 | const mounted = this.deps.resolveKey(entry); |
| 179 | if (mounted !== undefined) { |
| 180 | if (!current()) return; |
| 181 | this.deps.scroll.jump(mounted); |
| 182 | this.finish(entry, interaction); |
| 183 | return; |
| 184 | } |
| 185 | if (!this.deps.hasOlder()) { |
| 186 | // History is exhausted, but the last page mounts progressively. The |
| 187 | // data having covered the target is not the target being reachable |
| 188 | // yet, so keep waiting for its node before declaring it missing. |
| 189 | await this.drainTo(entry, interaction, current); |
| 190 | return; |
| 191 | } |
| 192 | if (pages >= MAX_JUMP_PAGES) { |
| 193 | // A budget running out is not the same as the turn not existing. |
| 194 | this.fail(entry, interaction, "pageBudgetExhausted"); |
| 195 | return; |
| 196 | } |
| 197 | pages++; |
| 198 | const before = this.deps.mounts.getSnapshot(); |
| 199 | const loaded = await this.deps.loadOlder(); |
| 200 | if (!current()) { this.bail(interaction); return; } |
| 201 | if (loaded === "stale") { |
| 202 | // The cut this jump resolved against was recycled. Replacing the body |
| 203 | // is the reader's decision, not a side effect of navigation. |
| 204 | this.fail(entry, interaction, "snapshotExpired"); |
| 205 | return; |
| 206 | } |
| 207 | if (loaded === "empty") { |
| 208 | // A page the host could not fill while still claiming older history |
| 209 | // is a dead end, not a recycled cut; only exhaustion earns the |
| 210 | // progressive-mount wait. |
| 211 | if (this.deps.hasOlder()) { |
| 212 | this.fail(entry, interaction, "turnUnavailable"); |
| 213 | return; |
| 214 | } |
| 215 | await this.drainTo(entry, interaction, current); |
| 216 | return; |
| 217 | } |
| 218 | await this.settleMounts(before); |
| 219 | if (!current()) { this.bail(interaction); return; } |
| 220 | } |
| 221 | } catch (error) { |
| 222 | this.fail(entry, interaction, error instanceof Error && error.message === "stale" ? "snapshotExpired" : "turnUnavailable"); |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | /** |
| 227 | * Wait for the batched mount to advance, bounded so a page that adds no new |
| 228 | * turn cannot stall the loop or spin the network. |
| 229 | */ |
| 230 | private settleMounts(previous: readonly string[]): Promise<void> { |
| 231 | // A page can already have advanced the mount while it was loading; the |
| 232 | // published reference is what changes, so compare against it rather than |
| 233 | // waiting for a publication that may never come. |
| 234 | if (this.deps.mounts.getSnapshot() !== previous) return Promise.resolve(); |
| 235 | return new Promise((resolve) => { |
| 236 | let elapsed = 0; |
| 237 | let handle = 0; |
| 238 | let settled = false; |
| 239 | const finish = (): void => { |
| 240 | if (settled) return; |
| 241 | settled = true; |
| 242 | if (handle) cancelAnimationFrame(handle); |
| 243 | clearTimeout(timer); |
| 244 | resolve(); |
| 245 | }; |
| 246 | const step = (): void => { |
| 247 | if (settled) return; |
| 248 | if (this.deps.mounts.getSnapshot() !== previous || elapsed >= MOUNT_SETTLE_FRAMES) { finish(); return; } |
| 249 | elapsed++; |
| 250 | handle = requestAnimationFrame(step); |
| 251 | }; |
| 252 | // A hidden or occluded window stops delivering frames, so a frame count |
| 253 | // alone can leave the mark pulsing until the window is shown again. Bound |
| 254 | // the wait by wall clock as well and let the next attempt re-check. |
| 255 | const timer = setTimeout(finish, MOUNT_SETTLE_MS); |
| 256 | handle = requestAnimationFrame(step); |
| 257 | }); |
| 258 | } |
| 259 | |
| 260 | /** |
| 261 | * Wait out the progressive mount once history is exhausted: the target may |
| 262 | * still be arriving on a later frame, so it is not missing yet. Ends the |
| 263 | * transaction either way. |
| 264 | */ |
| 265 | private async drainTo(entry: TranscriptOutlineEntry, interaction: number, current: () => boolean): Promise<void> { |
| 266 | const mounted = await this.waitForMount(entry, current); |
| 267 | if (!current()) { this.bail(interaction); return; } |
| 268 | if (mounted !== undefined) { |
| 269 | this.deps.scroll.jump(mounted); |
| 270 | this.finish(entry, interaction); |
| 271 | return; |
| 272 | } |
| 273 | this.fail(entry, interaction, "turnUnavailable"); |
| 274 | } |
| 275 | |
| 276 | /** |
| 277 | * Keep polling for the target's node after history is exhausted, so a |
| 278 | * progressively revealed last page is not mistaken for a missing turn. |
| 279 | * Returns its key, or undefined when the wait budget expires. |
| 280 | */ |
| 281 | private waitForMount(entry: TranscriptOutlineEntry, current: () => boolean): Promise<string | undefined> { |
| 282 | const found = this.deps.resolveKey(entry); |
| 283 | if (found !== undefined) return Promise.resolve(found); |
| 284 | return new Promise((resolve) => { |
| 285 | let handle = 0; |
| 286 | let settled = false; |
| 287 | const finish = (key: string | undefined): void => { |
| 288 | if (settled) return; |
| 289 | settled = true; |
| 290 | if (handle) cancelAnimationFrame(handle); |
| 291 | clearTimeout(timer); |
| 292 | resolve(key); |
| 293 | }; |
| 294 | const step = (): void => { |
| 295 | if (settled) return; |
| 296 | if (!current()) { finish(undefined); return; } |
| 297 | const key = this.deps.resolveKey(entry); |
| 298 | if (key !== undefined) { finish(key); return; } |
| 299 | handle = requestAnimationFrame(step); |
| 300 | }; |
| 301 | const timer = setTimeout(() => { finish(undefined); }, this.deps.drainMs ?? DRAIN_MOUNT_MS); |
| 302 | handle = requestAnimationFrame(step); |
| 303 | }); |
| 304 | } |
| 305 | |
| 306 | /** |
| 307 | * Release the state a jump still owns after it stops early. A superseding |
| 308 | * interaction owns the state itself, so only the current one may clear it — |
| 309 | * otherwise a stale loop would wipe the mark a newer click just set. |
| 310 | */ |
| 311 | private bail(interaction: number): void { |
| 312 | if (this.interaction !== interaction) return; |
| 313 | this.detach(); |
| 314 | this.publish(IDLE); |
| 315 | } |
| 316 | |
| 317 | private finish(entry: TranscriptOutlineEntry, interaction: number): void { |
| 318 | if (this.interaction !== interaction) return; |
| 319 | this.detach(); |
| 320 | this.publish({ turn: entry.id, status: "idle" }); |
| 321 | } |
| 322 | |
| 323 | private fail(entry: TranscriptOutlineEntry, interaction: number, reason: TurnJumpReason): void { |
| 324 | if (this.interaction !== interaction) return; |
| 325 | this.detach(); |
| 326 | this.publish({ turn: entry.id, status: "failed", reason, retry: entry }); |
| 327 | } |
| 328 | |
| 329 | private publish(state: TurnJumpState): void { |
| 330 | this.state = state; |
| 331 | for (const listener of [...this.listeners]) listener(); |
| 332 | } |
| 333 | } |
| 334 |