| 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 |