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