返回 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 { 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
92 lines TYPESCRIPT