1import { useAtomValue } from "@effect/atom-react";
2
3import type { EnvironmentId, ReviewDiffPreviewSource, ThreadId } from "@t3tools/contracts";
4import { Atom } from "effect/reactivity";
5
6import { scopedThreadKey } from "../../lib/scopedEntities";
7import { appAtomRegistry } from "../../state/atom-registry";
8import { buildReviewParsedDiff, type ReviewParsedDiff } from "./reviewModel";
9
10const EMPTY_GIT_REVIEW_SECTIONS = Object.freeze<ReadonlyArray<ReviewDiffPreviewSource>>([]);
11const EMPTY_REVIEW_TURN_DIFFS = Object.freeze<Readonly<Record<string, string>>>({});
12const EMPTY_REVIEW_LOADING_TURN_IDS = Object.freeze<Readonly<Record<string, boolean>>>({});
13const EMPTY_REVIEW_ASYNC_STATE = Object.freeze<ReviewAsyncState>({
14 loadingTurnIds: EMPTY_REVIEW_LOADING_TURN_IDS,
15 error: null,
16});
17const EMPTY_REVIEW_SECTION_FILE_IDS = Object.freeze<
18 Readonly<Record<string, ReadonlyArray<string> | undefined>>
19>({});
20const EMPTY_REVIEW_GIT_SECTIONS_ATOM = Atom.make(EMPTY_GIT_REVIEW_SECTIONS).pipe(
21 Atom.keepAlive,
22 Atom.withLabel("mobile:review:git-sections:null"),
23);
24const EMPTY_REVIEW_TURN_DIFFS_ATOM = Atom.make(EMPTY_REVIEW_TURN_DIFFS).pipe(
25 Atom.keepAlive,
26 Atom.withLabel("mobile:review:turn-diffs:null"),
27);
28const EMPTY_REVIEW_SELECTED_SECTION_ID_ATOM = Atom.make<string | null>(null).pipe(
29 Atom.keepAlive,
30 Atom.withLabel("mobile:review:selected-section-id:null"),
31);
32const EMPTY_REVIEW_ASYNC_STATE_ATOM = Atom.make(EMPTY_REVIEW_ASYNC_STATE).pipe(
33 Atom.keepAlive,
34 Atom.withLabel("mobile:review:async-state:null"),
35);
36const EMPTY_REVIEW_SECTION_FILE_IDS_ATOM = Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe(
37 Atom.keepAlive,
38 Atom.withLabel("mobile:review:section-file-ids:null"),
39);
40
41const reviewGitSectionsByThreadKeyAtom = Atom.family((threadKey: string) =>
42 Atom.make(EMPTY_GIT_REVIEW_SECTIONS).pipe(
43 Atom.keepAlive,
44 Atom.withLabel(`mobile:review:git-sections:${threadKey}`),
45 ),
46);
47
48const reviewTurnDiffByThreadKeyAtom = Atom.family((threadKey: string) =>
49 Atom.make(EMPTY_REVIEW_TURN_DIFFS).pipe(
50 Atom.keepAlive,
51 Atom.withLabel(`mobile:review:turn-diffs:${threadKey}`),
52 ),
53);
54
55const reviewSelectedSectionIdByThreadKeyAtom = Atom.family((threadKey: string) =>
56 Atom.make<string | null>(null).pipe(
57 Atom.keepAlive,
58 Atom.withLabel(`mobile:review:selected-section-id:${threadKey}`),
59 ),
60);
61
62const reviewAsyncStateByThreadKeyAtom = Atom.family((threadKey: string) =>
63 Atom.make(EMPTY_REVIEW_ASYNC_STATE).pipe(
64 Atom.keepAlive,
65 Atom.withLabel(`mobile:review:async-state:${threadKey}`),
66 ),
67);
68
69const reviewExpandedFileIdsByThreadKeyAtom = Atom.family((threadKey: string) =>
70 Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe(
71 Atom.keepAlive,
72 Atom.withLabel(`mobile:review:expanded-file-ids:${threadKey}`),
73 ),
74);
75
76const reviewRevealedLargeFileIdsByThreadKeyAtom = Atom.family((threadKey: string) =>
77 Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe(
78 Atom.keepAlive,
79 Atom.withLabel(`mobile:review:revealed-large-file-ids:${threadKey}`),
80 ),
81);
82
83const reviewViewedFileIdsByThreadKeyAtom = Atom.family((threadKey: string) =>
84 Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe(
85 Atom.keepAlive,
86 Atom.withLabel(`mobile:review:viewed-file-ids:${threadKey}`),
87 ),
88);
89
90export const MAX_CACHED_REVIEW_DIFFS = 8;
91// This bounds source string length, not the parsed or native heap size.
92export const MAX_CACHED_REVIEW_SOURCE_CHARACTERS = 4 * 1024 * 1024;
93
94interface CachedReviewParsedDiff {
95 readonly diff: string | null;
96 readonly parsed: ReviewParsedDiff;
97 readonly sourceCharacterCount: number;
98}
99
100// The factory keeps this mutable cache local to the registry and releases it on reset or disposal.
101const reviewParsedDiffCacheAtom = Atom.make(() => ({
102 entries: new Map<string, CachedReviewParsedDiff>(),
103 sourceCharacterCount: 0,
104})).pipe(Atom.keepAlive, Atom.withLabel("mobile:review:parsed-diffs"));
105
106export interface ReviewCacheForThread {
107 readonly threadKey: string | null;
108 readonly gitSections: ReadonlyArray<ReviewDiffPreviewSource>;
109 readonly turnDiffById: Readonly<Record<string, string>>;
110 readonly selectedSectionId: string | null;
111 readonly asyncState: ReviewAsyncState;
112 readonly expandedFileIdsBySection: Readonly<Record<string, ReadonlyArray<string> | undefined>>;
113 readonly revealedLargeFileIdsBySection: Readonly<
114 Record<string, ReadonlyArray<string> | undefined>
115 >;
116 readonly viewedFileIdsBySection: Readonly<Record<string, ReadonlyArray<string> | undefined>>;
117}
118
119export interface ReviewAsyncState {
120 readonly loadingTurnIds: Readonly<Record<string, boolean>>;
121 readonly error: string | null;
122}
123
124function buildThreadKey(input: {
125 readonly environmentId?: EnvironmentId;
126 readonly threadId?: ThreadId;
127}): string | null {
128 return input.environmentId && input.threadId
129 ? scopedThreadKey(input.environmentId, input.threadId)
130 : null;
131}
132
133function buildSectionCacheKey(threadKey: string, sectionId: string): string {
134 return `${threadKey}:${sectionId}`;
135}
136
137export function useReviewCacheForThread(input: {
138 readonly environmentId?: EnvironmentId;
139 readonly threadId?: ThreadId;
140}): ReviewCacheForThread {
141 const threadKey = buildThreadKey(input);
142 const gitSections = useAtomValue(
143 threadKey ? reviewGitSectionsByThreadKeyAtom(threadKey) : EMPTY_REVIEW_GIT_SECTIONS_ATOM,
144 );
145 const turnDiffById = useAtomValue(
146 threadKey ? reviewTurnDiffByThreadKeyAtom(threadKey) : EMPTY_REVIEW_TURN_DIFFS_ATOM,
147 );
148 const selectedSectionId = useAtomValue(
149 threadKey
150 ? reviewSelectedSectionIdByThreadKeyAtom(threadKey)
151 : EMPTY_REVIEW_SELECTED_SECTION_ID_ATOM,
152 );
153 const asyncState = useAtomValue(
154 threadKey ? reviewAsyncStateByThreadKeyAtom(threadKey) : EMPTY_REVIEW_ASYNC_STATE_ATOM,
155 );
156 const expandedFileIdsBySection = useAtomValue(
157 threadKey
158 ? reviewExpandedFileIdsByThreadKeyAtom(threadKey)
159 : EMPTY_REVIEW_SECTION_FILE_IDS_ATOM,
160 );
161 const revealedLargeFileIdsBySection = useAtomValue(
162 threadKey
163 ? reviewRevealedLargeFileIdsByThreadKeyAtom(threadKey)
164 : EMPTY_REVIEW_SECTION_FILE_IDS_ATOM,
165 );
166 const viewedFileIdsBySection = useAtomValue(
167 threadKey ? reviewViewedFileIdsByThreadKeyAtom(threadKey) : EMPTY_REVIEW_SECTION_FILE_IDS_ATOM,
168 );
169
170 return {
171 threadKey,
172 gitSections,
173 turnDiffById,
174 selectedSectionId,
175 asyncState,
176 expandedFileIdsBySection,
177 revealedLargeFileIdsBySection,
178 viewedFileIdsBySection,
179 };
180}
181
182export function setReviewGitSections(
183 threadKey: string,
184 sections: ReadonlyArray<ReviewDiffPreviewSource>,
185): void {
186 appAtomRegistry.set(reviewGitSectionsByThreadKeyAtom(threadKey), sections);
187}
188
189export function setReviewTurnDiff(threadKey: string, sectionId: string, diff: string): void {
190 const atom = reviewTurnDiffByThreadKeyAtom(threadKey);
191 const current = appAtomRegistry.get(atom);
192 appAtomRegistry.set(atom, {
193 ...current,
194 [sectionId]: diff,
195 });
196}
197
198export function setReviewSelectedSectionId(threadKey: string, sectionId: string | null): void {
199 appAtomRegistry.set(reviewSelectedSectionIdByThreadKeyAtom(threadKey), sectionId);
200}
201
202function updateReviewAsyncState(
203 threadKey: string,
204 update: (current: ReviewAsyncState) => ReviewAsyncState,
205): void {
206 const atom = reviewAsyncStateByThreadKeyAtom(threadKey);
207 appAtomRegistry.set(atom, update(appAtomRegistry.get(atom)));
208}
209
210export function setReviewTurnDiffLoading(
211 threadKey: string,
212 sectionId: string,
213 isLoading: boolean,
214): void {
215 updateReviewAsyncState(threadKey, (current) => {
216 const loadingTurnIds = { ...current.loadingTurnIds };
217 if (isLoading) {
218 loadingTurnIds[sectionId] = true;
219 } else {
220 delete loadingTurnIds[sectionId];
221 }
222 return {
223 ...current,
224 loadingTurnIds,
225 };
226 });
227}
228
229export function setReviewAsyncError(threadKey: string, error: string | null): void {
230 updateReviewAsyncState(threadKey, (current) => ({
231 ...current,
232 error,
233 }));
234}
235
236export function getReviewAsyncStateSnapshot(threadKey: string): ReviewAsyncState {
237 return appAtomRegistry.get(reviewAsyncStateByThreadKeyAtom(threadKey));
238}
239
240export function updateReviewExpandedFileIds(
241 threadKey: string,
242 sectionId: string,
243 update: (current: ReadonlyArray<string> | undefined) => ReadonlyArray<string> | undefined,
244): void {
245 const atom = reviewExpandedFileIdsByThreadKeyAtom(threadKey);
246 const current = appAtomRegistry.get(atom);
247 const nextValue = update(current[sectionId]);
248 appAtomRegistry.set(atom, {
249 ...current,
250 [sectionId]: nextValue,
251 });
252}
253
254export function updateReviewViewedFileIds(
255 threadKey: string,
256 sectionId: string,
257 update: (current: ReadonlyArray<string> | undefined) => ReadonlyArray<string> | undefined,
258): void {
259 const atom = reviewViewedFileIdsByThreadKeyAtom(threadKey);
260 const current = appAtomRegistry.get(atom);
261 const nextValue = update(current[sectionId]);
262 appAtomRegistry.set(atom, {
263 ...current,
264 [sectionId]: nextValue,
265 });
266}
267
268/** Returns the larger of current input and matching cached source, without changing recency. */
269export function getReviewParsedDiffSourceCharacterCount(input: {
270 readonly threadKey: string;
271 readonly sectionId: string;
272 readonly diff: string | null;
273}): number {
274 const sourceCharacterCount = input.diff?.length ?? 0;
275 const cache = appAtomRegistry.get(reviewParsedDiffCacheAtom);
276 const cached = cache.entries.get(buildSectionCacheKey(input.threadKey, input.sectionId));
277 return cached && cached.diff === (input.diff?.trim() ?? null)
278 ? Math.max(sourceCharacterCount, cached.sourceCharacterCount)
279 : sourceCharacterCount;
280}
281
282export function getCachedReviewParsedDiff(input: {
283 readonly threadKey: string | null;
284 readonly sectionId: string | null;
285 readonly diff: string | null | undefined;
286}): ReviewParsedDiff {
287 if (!input.threadKey || !input.sectionId) {
288 return buildReviewParsedDiff(input.diff, input.sectionId ?? "mobile-review");
289 }
290
291 const cacheKey = buildSectionCacheKey(input.threadKey, input.sectionId);
292 const normalizedDiff = input.diff?.trim() ?? null;
293 const cache = appAtomRegistry.get(reviewParsedDiffCacheAtom);
294 const cached = cache.entries.get(cacheKey);
295 if (cached && cached.diff === normalizedDiff) {
296 cache.entries.delete(cacheKey);
297 cache.entries.set(cacheKey, cached);
298 return cached.parsed;
299 }
300
301 const parsed = buildReviewParsedDiff(input.diff, input.sectionId);
302 if (cached) {
303 cache.entries.delete(cacheKey);
304 cache.sourceCharacterCount -= cached.sourceCharacterCount;
305 }
306 const sourceCharacterCount = input.diff?.length ?? 0;
307 if (sourceCharacterCount > MAX_CACHED_REVIEW_SOURCE_CHARACTERS) {
308 return parsed;
309 }
310
311 for (const [oldestKey, oldest] of cache.entries) {
312 if (
313 cache.entries.size < MAX_CACHED_REVIEW_DIFFS &&
314 cache.sourceCharacterCount + sourceCharacterCount <= MAX_CACHED_REVIEW_SOURCE_CHARACTERS
315 ) {
316 break;
317 }
318 cache.entries.delete(oldestKey);
319 cache.sourceCharacterCount -= oldest.sourceCharacterCount;
320 }
321 cache.entries.set(cacheKey, {
322 diff: normalizedDiff,
323 parsed,
324 sourceCharacterCount,
325 });
326 cache.sourceCharacterCount += sourceCharacterCount;
327 return parsed;
328}