返回 DeepSeek-Reasonix
historyWindowController.ts
根目录 / desktop / frontend / src / lib / historyWindowController.ts
1 import { addBreadcrumb } from "./breadcrumbs";
2 import { historyPageRequestBudget } from "./historyPaging";
3 import { getTranscriptStore, type TranscriptProjection } from "./transcriptStore";
4 import { hydrateIdentityCurrent, type SessionIdentity } from "./sessionIdentity";
5
6 export type HistoryWindowLoadOutcome = "loaded" | "empty" | "stale";
7 export type HistoryWindowDirection = "older" | "newer" | "latest";
8
9 export type HistoryWindowState = {
10 transcriptProtocol?: number;
11 historyStartTurn: number;
12 historyTotalTurns: number;
13 historyHasOlder: boolean;
14 historyHasNewer: boolean;
15 historyOlderLoading: boolean;
16 historyNewerLoading: boolean;
17 historyRevision?: number;
18 historyDigest?: string;
19 running: boolean;
20 meta?: SessionIdentity & { sessionRevision?: number; sessionDigest?: string };
21 };
22
23 export type HistoryWindowAction =
24 | { type: "history_older_start" }
25 | { type: "history_older_error"; error?: string }
26 | { type: "history_newer_start" }
27 | { type: "history_newer_error"; error?: string }
28 | ({ type: "history_replace"; items: TranscriptProjection["items"] } & HistoryWindowProjection)
29 | ({ type: "history_prepend"; items: TranscriptProjection["items"]; removeIds: string[] } & HistoryWindowProjection)
30 | ({ type: "history_append"; items: TranscriptProjection["items"] } & HistoryWindowProjection)
31 | { type: "transcript_records"; projection: import("./transcriptStore").AppendEntriesResult; confirmedUsers: [] };
32
33 type HistoryWindowProjection = {
34 startTurn: number;
35 endTurn: number;
36 totalTurns: number;
37 hasOlder: boolean;
38 hasNewer: boolean;
39 revision?: number;
40 digest?: string;
41 };
42
43 type LoadInput = {
44 tabId: string;
45 direction: HistoryWindowDirection;
46 targetTurn?: number;
47 trigger: string;
48 state: HistoryWindowState;
49 requestSeq: number;
50 isCurrent: (requestSeq: number) => boolean;
51 readerCurrent?: () => boolean;
52 currentState: () => HistoryWindowState | undefined;
53 dispatch: (action: HistoryWindowAction) => void;
54 };
55
56 function projectionFields(projection: TranscriptProjection): HistoryWindowProjection {
57 return {
58 startTurn: projection.startTurn,
59 endTurn: projection.endTurn,
60 totalTurns: projection.totalTurns,
61 hasOlder: projection.hasOlder,
62 hasNewer: projection.hasNewer,
63 revision: projection.revisionKnown ? projection.revision : undefined,
64 digest: projection.digest || undefined,
65 };
66 }
67
68 function fingerprintMatches(expected: number | undefined, actual: number | undefined): boolean {
69 return expected === undefined || expected <= 0 || actual === expected;
70 }
71
72 function digestMatches(expected: string | undefined, actual: string | undefined): boolean {
73 return !expected || actual === expected;
74 }
75
76 /** Runs one protocol-7 window transition without owning React state or scroll. */
77 export async function loadHistoryWindow(input: LoadInput): Promise<HistoryWindowLoadOutcome> {
78 const { tabId, direction, state } = input;
79 if (state.running && state.transcriptProtocol !== 2) return "empty";
80 if (direction === "older" && (!state.historyHasOlder || state.historyOlderLoading)) return "empty";
81 if (direction === "newer" && (!state.historyHasNewer || state.historyNewerLoading)) return "empty";
82 const sessionPath = state.meta?.sessionPath ?? "";
83 const sessionIdentity = state.meta ?? {};
84 const expectedRevision = state.transcriptProtocol === 2 ? undefined : state.meta?.sessionRevision ?? state.historyRevision;
85 const expectedDigest = state.transcriptProtocol === 2 ? state.historyDigest : state.meta?.sessionDigest ?? state.historyDigest;
86 const request = {
87 ...historyPageRequestBudget(state.historyStartTurn, state.historyTotalTurns, input.targetTurn),
88 current: () => input.isCurrent(input.requestSeq) && (input.readerCurrent?.() ?? true) && hydrateIdentityCurrent(sessionIdentity, input.currentState()?.meta),
89 };
90 input.dispatch({ type: direction === "older" ? "history_older_start" : "history_newer_start" });
91 const startedAt = Date.now();
92 try {
93 const store = getTranscriptStore();
94 if (direction === "older") {
95 const result = await store.loadOlder(tabId, sessionPath, request);
96 if (!input.isCurrent(input.requestSeq)) return "empty";
97 const current = input.currentState();
98 if (!current || !current.historyOlderLoading || !hydrateIdentityCurrent(sessionIdentity, current.meta) ||
99 !fingerprintMatches(expectedRevision, current.meta?.sessionRevision ?? current.historyRevision) ||
100 !digestMatches(expectedDigest, current.transcriptProtocol === 2 ? current.historyDigest : current.meta?.sessionDigest ?? current.historyDigest)) {
101 input.dispatch({ type: "history_older_error", error: "history identity changed" });
102 return "empty";
103 }
104 if (!result) { input.dispatch({ type: "history_older_error", error: "history page unavailable" }); return "empty"; }
105 if (result.kind !== "reload" && (!fingerprintMatches(expectedRevision, result.revisionKnown ? result.revision : undefined) || !digestMatches(expectedDigest, result.digest))) {
106 input.dispatch({ type: "history_older_error", error: "history identity changed" });
107 return "empty";
108 }
109 if (state.transcriptProtocol === 2) input.dispatch({ type: "transcript_records", projection: result, confirmedUsers: [] });
110 else if (result.kind === "reload") input.dispatch({ type: "history_replace", items: result.items, ...projectionFields(result) });
111 else input.dispatch({ type: "history_prepend", items: result.prependItems, removeIds: result.removeIds, ...projectionFields(result) });
112 addBreadcrumb("tab.hydrate", `history older ${tabId} trigger=${input.trigger} turns=${result.startTurn}-${result.endTurn}/${result.totalTurns} ms=${Date.now() - startedAt}`);
113 return "loaded";
114 }
115 if (direction === "newer") {
116 const result = await store.loadNewer(tabId, sessionPath, request);
117 if (!input.isCurrent(input.requestSeq)) return "empty";
118 const current = input.currentState();
119 if (!current || !current.historyNewerLoading || !hydrateIdentityCurrent(sessionIdentity, current.meta) ||
120 !fingerprintMatches(expectedRevision, current.meta?.sessionRevision ?? current.historyRevision) ||
121 !digestMatches(expectedDigest, current.transcriptProtocol === 2 ? current.historyDigest : current.meta?.sessionDigest ?? current.historyDigest)) {
122 input.dispatch({ type: "history_newer_error", error: "history identity changed" });
123 return "empty";
124 }
125 if (!result) { input.dispatch({ type: "history_newer_error", error: "history page unavailable" }); return "empty"; }
126 if (!fingerprintMatches(expectedRevision, result.revisionKnown ? result.revision : undefined) || !digestMatches(expectedDigest, result.digest)) {
127 input.dispatch({ type: "history_newer_error", error: "history identity changed" });
128 return "empty";
129 }
130 if (result.kind === "stale") {
131 input.dispatch({ type: "history_newer_error", error: "history snapshot expired" });
132 return "stale";
133 }
134 if (state.transcriptProtocol === 2) input.dispatch({ type: "transcript_records", projection: result, confirmedUsers: [] });
135 else input.dispatch({ type: "history_append", items: result.items, ...projectionFields(result) });
136 addBreadcrumb("tab.hydrate", `history newer ${tabId} trigger=${input.trigger} turns=${result.startTurn}-${result.endTurn}/${result.totalTurns} ms=${Date.now() - startedAt}`);
137 return "loaded";
138 }
139 const result = await store.loadLatest(tabId, sessionPath, { ...request, preferResident: false });
140 if (!input.isCurrent(input.requestSeq)) return "empty";
141 if (input.readerCurrent?.() === false) {
142 input.dispatch({ type: "history_newer_error", error: "" });
143 return "empty";
144 }
145 const current = input.currentState();
146 if (!current) return "empty";
147 const currentRevision = current.meta?.sessionRevision ?? current.historyRevision;
148 const currentDigest = current.transcriptProtocol === 2 ? current.historyDigest : current.meta?.sessionDigest ?? current.historyDigest;
149 if (!current.historyNewerLoading || !hydrateIdentityCurrent(sessionIdentity, current.meta) ||
150 !fingerprintMatches(expectedRevision, currentRevision) || !digestMatches(expectedDigest, currentDigest)) {
151 input.dispatch({ type: "history_newer_error", error: "history identity changed" });
152 return "empty";
153 }
154 if (!result) {
155 input.dispatch({ type: "history_newer_error", error: "history page unavailable" });
156 return "empty";
157 }
158 input.dispatch({ type: "history_replace", items: result.items, ...projectionFields(result) });
159 addBreadcrumb("tab.hydrate", `history ${direction} ${tabId} trigger=${input.trigger} turns=${result.startTurn}-${result.endTurn}/${result.totalTurns} ms=${Date.now() - startedAt}`);
160 return "loaded";
161 } catch (error) {
162 if (!input.isCurrent(input.requestSeq) || !input.currentState()) return "empty";
163 const message = error instanceof Error ? error.message : String(error);
164 input.dispatch({ type: direction === "older" ? "history_older_error" : "history_newer_error", error: message });
165 addBreadcrumb("tab.hydrate", `history ${direction} failed ${tabId}: ${message}`);
166 return "empty";
167 }
168 }
169
169 lines TYPESCRIPT