1import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
2import { environmentSession } from "../../state/session";
3import { useCallback, useEffect, useMemo } from "react";
4import * as DateTime from "effect/DateTime";
5
6import {
7 deriveThreadCheckpointSummaries,
8 type ThreadCheckpointSummary,
9} from "@t3tools/client-runtime/state/thread-checkpoints";
10import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
11
12import { useCheckpointDiff } from "../../state/queries";
13import { useEnvironmentQuery } from "../../state/query";
14import { useEnvironmentPresentation } from "../../state/presentation";
15import { reviewEnvironment } from "../../state/review";
16import { useSelectedThreadProjection } from "../../state/use-thread-detail";
17import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
18import {
19 buildReviewSectionItems,
20 getDefaultReviewSectionId,
21 getReadyReviewCheckpoints,
22 getReviewSectionIdForCheckpoint,
23} from "./reviewModel";
24import {
25 setReviewAsyncError,
26 setReviewGitSections,
27 setReviewSelectedSectionId,
28 setReviewTurnDiff,
29 setReviewTurnDiffLoading,
30 type ReviewCacheForThread,
31} from "./reviewState";
32
33export function useReviewSections(input: {
34 readonly enabled?: boolean;
35 readonly environmentId?: EnvironmentId;
36 readonly threadId?: ThreadId;
37 readonly reviewCache: ReviewCacheForThread;
38}) {
39 const { environmentId, reviewCache, threadId } = input;
40 const enabled = input.enabled ?? true;
41 const fileAccessSession = useEnvironmentQuery(
42 environmentId === undefined ? null : environmentSession.sessionStateAtom(environmentId),
43 );
44 const fileEnvironment = useEnvironmentPresentation(environmentId ?? null);
45 const fileAccess = resolveFilesystemReadAccess({
46 isCatalogReady: fileEnvironment.isReady,
47 connection: fileEnvironment.presentation?.connection ?? null,
48 session: fileAccessSession.data,
49 sessionError: fileAccessSession.error,
50 });
51 const { canReadFiles } = fileAccess;
52 const selectedThread = useSelectedThreadProjection();
53 const { selectedThreadCwd } = useSelectedThreadWorktree();
54 const diffPreview = useEnvironmentQuery(
55 canReadFiles && enabled && environmentId !== undefined && selectedThreadCwd !== null
56 ? reviewEnvironment.diffPreview({
57 environmentId,
58 input: { cwd: selectedThreadCwd },
59 })
60 : null,
61 );
62 const { loadingTurnIds } = reviewCache.asyncState;
63
64 useEffect(() => {
65 if (reviewCache.threadKey && diffPreview.data) {
66 setReviewGitSections(reviewCache.threadKey, diffPreview.data.sources);
67 }
68 }, [diffPreview.data, reviewCache.threadKey]);
69
70 const readyCheckpoints = useMemo(
71 () =>
72 getReadyReviewCheckpoints(
73 selectedThread === null ? [] : deriveThreadCheckpointSummaries(selectedThread.projection),
74 ),
75 [selectedThread],
76 );
77 const checkpointBySectionId = useMemo(
78 () =>
79 Object.fromEntries(
80 readyCheckpoints.map((checkpoint) => [
81 getReviewSectionIdForCheckpoint(checkpoint),
82 checkpoint,
83 ]),
84 ) as Record<string, ThreadCheckpointSummary>,
85 [readyCheckpoints],
86 );
87 const reviewSections = useMemo(() => {
88 const sections = buildReviewSectionItems({
89 checkpoints: readyCheckpoints,
90 gitSections:
91 canReadFiles || fileAccess.isPending
92 ? (diffPreview.data?.sources ?? reviewCache.gitSections)
93 : [],
94 turnDiffById: reviewCache.turnDiffById,
95 loadingTurnIds,
96 loadingGitSections: fileAccess.isPending || diffPreview.isPending,
97 });
98 // Keep the selected section while its grant loads, without displaying cached host files.
99 return fileAccess.isPending
100 ? sections.map((section) =>
101 section.kind === "turn" ? section : { ...section, diff: null, isLoading: true },
102 )
103 : sections;
104 }, [
105 canReadFiles,
106 diffPreview.data?.sources,
107 diffPreview.isPending,
108 fileAccess.isPending,
109 loadingTurnIds,
110 readyCheckpoints,
111 reviewCache.gitSections,
112 reviewCache.turnDiffById,
113 ]);
114 const selectedSection = useMemo(
115 () =>
116 reviewSections.find((section) => section.id === reviewCache.selectedSectionId) ??
117 reviewSections[0] ??
118 null,
119 [reviewCache.selectedSectionId, reviewSections],
120 );
121 const fallbackSectionId = useMemo(
122 () => getDefaultReviewSectionId(reviewSections),
123 [reviewSections],
124 );
125 const selectedSectionIdExists = useMemo(
126 () =>
127 reviewCache.selectedSectionId
128 ? reviewSections.some((section) => section.id === reviewCache.selectedSectionId)
129 : false,
130 [reviewCache.selectedSectionId, reviewSections],
131 );
132
133 useEffect(() => {
134 if (
135 reviewSections.length > 0 &&
136 reviewCache.threadKey &&
137 (!reviewCache.selectedSectionId || !selectedSectionIdExists)
138 ) {
139 setReviewSelectedSectionId(reviewCache.threadKey, fallbackSectionId);
140 }
141 }, [
142 fallbackSectionId,
143 reviewCache.selectedSectionId,
144 reviewCache.threadKey,
145 reviewSections.length,
146 selectedSectionIdExists,
147 ]);
148
149 let activeCheckpoint = readyCheckpoints[0] ?? null;
150 if (selectedSection?.kind === "turn") {
151 activeCheckpoint = checkpointBySectionId[selectedSection.id] ?? activeCheckpoint;
152 }
153 const activeSectionId = activeCheckpoint
154 ? getReviewSectionIdForCheckpoint(activeCheckpoint)
155 : null;
156 const activeTurnDiff = useCheckpointDiff({
157 environmentId: enabled ? (environmentId ?? null) : null,
158 threadId: enabled ? (threadId ?? null) : null,
159 fromTurnCount:
160 enabled && activeCheckpoint ? Math.max(0, activeCheckpoint.checkpointTurnCount - 1) : null,
161 toTurnCount: enabled ? (activeCheckpoint?.checkpointTurnCount ?? null) : null,
162 ignoreWhitespace: false,
163 });
164
165 useEffect(() => {
166 if (!reviewCache.threadKey || !activeSectionId) {
167 return;
168 }
169 setReviewTurnDiffLoading(reviewCache.threadKey, activeSectionId, activeTurnDiff.isPending);
170 }, [activeSectionId, activeTurnDiff.isPending, reviewCache.threadKey]);
171
172 useEffect(() => {
173 if (!reviewCache.threadKey || !activeSectionId || !activeTurnDiff.data) {
174 return;
175 }
176 setReviewTurnDiff(reviewCache.threadKey, activeSectionId, activeTurnDiff.data.diff);
177 setReviewAsyncError(reviewCache.threadKey, null);
178 }, [activeSectionId, activeTurnDiff.data, reviewCache.threadKey]);
179
180 useEffect(() => {
181 if (reviewCache.threadKey && activeTurnDiff.error) {
182 setReviewAsyncError(reviewCache.threadKey, activeTurnDiff.error);
183 }
184 }, [activeTurnDiff.error, reviewCache.threadKey]);
185
186 const refreshSelectedSection = useCallback(async () => {
187 if (!enabled) {
188 return;
189 }
190 if (selectedSection?.kind === "turn") {
191 activeTurnDiff.refresh();
192 return;
193 }
194 diffPreview.refresh();
195 }, [activeTurnDiff, diffPreview, enabled, selectedSection?.kind]);
196
197 const selectSection = useCallback(
198 (sectionId: string) => {
199 if (reviewCache.threadKey) {
200 setReviewSelectedSectionId(reviewCache.threadKey, sectionId);
201 }
202 },
203 [reviewCache.threadKey],
204 );
205
206 return {
207 error:
208 diffPreview.error ??
209 activeTurnDiff.error ??
210 reviewCache.asyncState.error ??
211 (selectedSection === null && !fileAccess.isPending && !canReadFiles
212 ? (fileAccess.error ?? "This connection cannot read local diffs.")
213 : null),
214 isSelectedSectionPending:
215 selectedSection?.kind === "turn" ? activeTurnDiff.isPending : diffPreview.isPending,
216 loadingGitDiffs: fileAccess.isPending || diffPreview.isPending,
217 diffPreviewRevision: diffPreview.data
218 ? DateTime.formatIso(diffPreview.data.generatedAt)
219 : undefined,
220 loadingTurnIds,
221 reviewSections,
222 selectedSection,
223 refreshSelectedSection,
224 selectSection,
225 };
226}