返回 DeepSeek-Reasonix
chatTurnJump.ts
根目录 / desktop / frontend / src / lib / chatTurnJump.ts
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
334 lines TYPESCRIPT