Skip to content

Commit 4ec9647

Browse files
Rename git state to VCS and add diff preview caching
- Replace git-scoped client/runtime state with VCS-scoped APIs - Add checkpoint diff preview state and reuse cached diffs - Update mobile and web review flows to use the new shared state
1 parent 01596f5 commit 4ec9647

40 files changed

Lines changed: 1114 additions & 749 deletions

apps/mobile/src/features/home/HomeScreen.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type {
22
EnvironmentScopedProjectShell,
33
EnvironmentScopedThreadShell,
4-
GitStatusState,
4+
VcsStatusState,
55
} from "@t3tools/client-runtime";
66
import { SymbolView } from "expo-symbols";
77
import { useCallback, useMemo, useState } from "react";
@@ -14,7 +14,7 @@ import { ProjectFavicon } from "../../components/ProjectFavicon";
1414
import type { SavedRemoteConnection } from "../../lib/connection";
1515
import { scopedProjectKey } from "../../lib/scopedEntities";
1616
import { relativeTime } from "../../lib/time";
17-
import { useGitStatus } from "../../state/use-git-status";
17+
import { useVcsStatus } from "../../state/use-vcs-status";
1818
import { threadStatusTone } from "../threads/threadPresentation";
1919

2020
/* ─── Types ──────────────────────────────────────────────────────────── */
@@ -97,7 +97,7 @@ function ProjectGroupLabel(props: {
9797

9898
/* ─── Git summary line ──────────────────────────────────────────────── */
9999

100-
function gitSummaryParts(gitStatus: GitStatusState): ReadonlyArray<string> {
100+
function gitSummaryParts(gitStatus: VcsStatusState): ReadonlyArray<string> {
101101
if (!gitStatus.data) return [];
102102
const { data } = gitStatus;
103103
const parts: string[] = [];
@@ -127,7 +127,7 @@ function ThreadRow(props: {
127127
// Subscribe to live git status — only when thread has a branch set.
128128
// Threads sharing the same cwd share one WS subscription via ref-counting.
129129
const cwd = branch ? (props.thread.worktreePath ?? props.projectCwd) : null;
130-
const gitStatus = useGitStatus({
130+
const gitStatus = useVcsStatus({
131131
environmentId: cwd ? props.thread.environmentId : null,
132132
cwd,
133133
});
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { useAtomValue } from "@effect/atom-react";
2+
import type { EnvironmentId, ReviewDiffPreviewResult } from "@t3tools/contracts";
3+
import * as Cause from "effect/Cause";
4+
import * as Effect from "effect/Effect";
5+
import * as Option from "effect/Option";
6+
import { AsyncResult, Atom } from "effect/unstable/reactivity";
7+
import { useCallback, useMemo } from "react";
8+
9+
import { appAtomRegistry } from "../../state/atom-registry";
10+
import { getEnvironmentClient } from "../../state/environment-session-registry";
11+
12+
const REVIEW_DIFF_PREVIEW_STALE_TIME_MS = 5_000;
13+
const REVIEW_DIFF_PREVIEW_IDLE_TTL_MS = 5 * 60_000;
14+
const REVIEW_DIFF_PREVIEW_KEY_SEPARATOR = "\u001f";
15+
16+
export interface ReviewDiffPreviewState {
17+
readonly data: ReviewDiffPreviewResult | null;
18+
readonly error: string | null;
19+
readonly isPending: boolean;
20+
readonly refresh: () => void;
21+
}
22+
23+
function makeReviewDiffPreviewKey(input: {
24+
readonly environmentId: EnvironmentId;
25+
readonly cwd: string;
26+
}): string {
27+
return `${input.environmentId}${REVIEW_DIFF_PREVIEW_KEY_SEPARATOR}${input.cwd}`;
28+
}
29+
30+
function parseReviewDiffPreviewKey(key: string): {
31+
readonly environmentId: EnvironmentId;
32+
readonly cwd: string;
33+
} {
34+
const [environmentId, cwd = ""] = key.split(REVIEW_DIFF_PREVIEW_KEY_SEPARATOR);
35+
return {
36+
environmentId: environmentId as EnvironmentId,
37+
cwd,
38+
};
39+
}
40+
41+
const reviewDiffPreviewAtom = Atom.family((key: string) =>
42+
Atom.make(
43+
Effect.promise(async (): Promise<ReviewDiffPreviewResult> => {
44+
const target = parseReviewDiffPreviewKey(key);
45+
const client = getEnvironmentClient(target.environmentId);
46+
if (!client) {
47+
throw new Error("Remote connection is not ready.");
48+
}
49+
return client.review.getDiffPreview({ cwd: target.cwd });
50+
}),
51+
).pipe(
52+
Atom.swr({
53+
staleTime: REVIEW_DIFF_PREVIEW_STALE_TIME_MS,
54+
revalidateOnMount: true,
55+
}),
56+
Atom.setIdleTTL(REVIEW_DIFF_PREVIEW_IDLE_TTL_MS),
57+
Atom.withLabel(`mobile:review:diff-preview:${key}`),
58+
),
59+
);
60+
61+
const EMPTY_REVIEW_DIFF_PREVIEW_RESULT_ATOM = Atom.make(
62+
AsyncResult.initial<ReviewDiffPreviewResult, never>(false),
63+
).pipe(Atom.keepAlive, Atom.withLabel("mobile:review:diff-preview:null"));
64+
65+
function readReviewDiffPreviewError(
66+
result: AsyncResult.AsyncResult<ReviewDiffPreviewResult, unknown>,
67+
): string | null {
68+
if (result._tag !== "Failure") {
69+
return null;
70+
}
71+
72+
const error = Cause.squash(result.cause);
73+
return error instanceof Error ? error.message : "Failed to load review diffs.";
74+
}
75+
76+
export function useReviewDiffPreview(input: {
77+
readonly environmentId?: EnvironmentId;
78+
readonly cwd: string | null;
79+
}): ReviewDiffPreviewState {
80+
const key = useMemo(() => {
81+
if (!input.environmentId || !input.cwd) {
82+
return null;
83+
}
84+
return makeReviewDiffPreviewKey({ environmentId: input.environmentId, cwd: input.cwd });
85+
}, [input.cwd, input.environmentId]);
86+
87+
const atom = key ? reviewDiffPreviewAtom(key) : null;
88+
const result = useAtomValue(atom ?? EMPTY_REVIEW_DIFF_PREVIEW_RESULT_ATOM);
89+
const refresh = useCallback(() => {
90+
if (atom) {
91+
appAtomRegistry.refresh(atom);
92+
}
93+
}, [atom]);
94+
95+
if (!atom) {
96+
return {
97+
data: null,
98+
error: null,
99+
isPending: false,
100+
refresh,
101+
};
102+
}
103+
104+
return {
105+
data: Option.getOrNull(AsyncResult.value(result)),
106+
error: readReviewDiffPreviewError(result),
107+
isPending: result.waiting,
108+
refresh,
109+
};
110+
}

apps/mobile/src/features/review/reviewState.test.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,24 @@ import { assert, it } from "vitest";
33
import {
44
getReviewAsyncStateSnapshot,
55
setReviewAsyncError,
6-
setReviewGitDiffsLoading,
76
setReviewTurnDiffLoading,
87
} from "./reviewState";
98

109
it("stores review async loading and error state in atoms", () => {
1110
const threadKey = `env-local:thread-review-state-${Date.now()}`;
1211

13-
setReviewGitDiffsLoading(threadKey, true);
1412
setReviewTurnDiffLoading(threadKey, "turn-1", true);
1513
setReviewAsyncError(threadKey, "load failed");
1614

1715
assert.deepStrictEqual(getReviewAsyncStateSnapshot(threadKey), {
18-
loadingGitDiffs: true,
1916
loadingTurnIds: { "turn-1": true },
2017
error: "load failed",
2118
});
2219

23-
setReviewGitDiffsLoading(threadKey, false);
2420
setReviewTurnDiffLoading(threadKey, "turn-1", false);
2521
setReviewAsyncError(threadKey, null);
2622

2723
assert.deepStrictEqual(getReviewAsyncStateSnapshot(threadKey), {
28-
loadingGitDiffs: false,
2924
loadingTurnIds: {},
3025
error: null,
3126
});

apps/mobile/src/features/review/reviewState.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ const EMPTY_GIT_REVIEW_SECTIONS = Object.freeze<ReadonlyArray<ReviewDiffPreviewS
1111
const EMPTY_REVIEW_TURN_DIFFS = Object.freeze<Readonly<Record<string, string>>>({});
1212
const EMPTY_REVIEW_LOADING_TURN_IDS = Object.freeze<Readonly<Record<string, boolean>>>({});
1313
const EMPTY_REVIEW_ASYNC_STATE = Object.freeze<ReviewAsyncState>({
14-
loadingGitDiffs: false,
1514
loadingTurnIds: EMPTY_REVIEW_LOADING_TURN_IDS,
1615
error: null,
1716
});
@@ -109,7 +108,6 @@ export interface ReviewCacheForThread {
109108
}
110109

111110
export interface ReviewAsyncState {
112-
readonly loadingGitDiffs: boolean;
113111
readonly loadingTurnIds: Readonly<Record<string, boolean>>;
114112
readonly error: string | null;
115113
}
@@ -200,13 +198,6 @@ function updateReviewAsyncState(
200198
appAtomRegistry.set(atom, update(appAtomRegistry.get(atom)));
201199
}
202200

203-
export function setReviewGitDiffsLoading(threadKey: string, loadingGitDiffs: boolean): void {
204-
updateReviewAsyncState(threadKey, (current) => ({
205-
...current,
206-
loadingGitDiffs,
207-
}));
208-
}
209-
210201
export function setReviewTurnDiffLoading(
211202
threadKey: string,
212203
sectionId: string,

apps/mobile/src/features/review/useReviewSections.ts

Lines changed: 36 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import { useCallback, useEffect, useMemo, useRef } from "react";
33
import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts";
44

55
import { getEnvironmentClient } from "../../state/environment-session-registry";
6+
import { checkpointDiffManager, loadCheckpointDiff } from "../../state/use-checkpoint-diff";
67
import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
78
import { useSelectedThreadDetail } from "../../state/use-thread-detail";
9+
import { useReviewDiffPreview } from "./reviewDiffPreviewState";
810
import {
911
buildReviewSectionItems,
1012
getDefaultReviewSectionId,
@@ -13,7 +15,6 @@ import {
1315
} from "./reviewModel";
1416
import {
1517
setReviewAsyncError,
16-
setReviewGitDiffsLoading,
1718
setReviewGitSections,
1819
setReviewSelectedSectionId,
1920
setReviewTurnDiffLoading,
@@ -29,13 +30,23 @@ export function useReviewSections(input: {
2930
const { environmentId, reviewCache, threadId } = input;
3031
const selectedThread = useSelectedThreadDetail();
3132
const { selectedThreadCwd } = useSelectedThreadWorktree();
32-
const { error, loadingGitDiffs, loadingTurnIds } = reviewCache.asyncState;
33+
const diffPreview = useReviewDiffPreview({ environmentId, cwd: selectedThreadCwd });
34+
const refreshDiffPreview = diffPreview.refresh;
35+
const { loadingTurnIds } = reviewCache.asyncState;
36+
const error = diffPreview.error ?? reviewCache.asyncState.error;
37+
const loadingGitDiffs = diffPreview.isPending;
3338
const turnDiffByIdRef = useRef(reviewCache.turnDiffById);
3439

3540
useEffect(() => {
3641
turnDiffByIdRef.current = reviewCache.turnDiffById;
3742
}, [reviewCache.turnDiffById]);
3843

44+
useEffect(() => {
45+
if (reviewCache.threadKey && diffPreview.data) {
46+
setReviewGitSections(reviewCache.threadKey, diffPreview.data.sources);
47+
}
48+
}, [diffPreview.data, reviewCache.threadKey]);
49+
3950
const readyCheckpoints = useMemo(
4051
() => getReadyReviewCheckpoints(selectedThread?.checkpoints ?? []),
4152
[selectedThread?.checkpoints],
@@ -78,42 +89,6 @@ export function useReviewSections(input: {
7889
[reviewCache.selectedSectionId, reviewSections],
7990
);
8091

81-
const loadGitDiffs = useCallback(async () => {
82-
if (!environmentId || !selectedThreadCwd) {
83-
return;
84-
}
85-
86-
const client = getEnvironmentClient(environmentId);
87-
if (!client) {
88-
if (reviewCache.threadKey) {
89-
setReviewAsyncError(reviewCache.threadKey, "Remote connection is not ready.");
90-
}
91-
return;
92-
}
93-
94-
if (reviewCache.threadKey) {
95-
setReviewGitDiffsLoading(reviewCache.threadKey, true);
96-
setReviewAsyncError(reviewCache.threadKey, null);
97-
}
98-
try {
99-
const result = await client.review.getDiffPreview({ cwd: selectedThreadCwd });
100-
if (reviewCache.threadKey) {
101-
setReviewGitSections(reviewCache.threadKey, result.sources);
102-
}
103-
} catch (cause) {
104-
if (reviewCache.threadKey) {
105-
setReviewAsyncError(
106-
reviewCache.threadKey,
107-
cause instanceof Error ? cause.message : "Failed to load review diffs.",
108-
);
109-
}
110-
} finally {
111-
if (reviewCache.threadKey) {
112-
setReviewGitDiffsLoading(reviewCache.threadKey, false);
113-
}
114-
}
115-
}, [environmentId, reviewCache.threadKey, selectedThreadCwd]);
116-
11792
const loadTurnDiff = useCallback(
11893
async (checkpoint: OrchestrationCheckpointSummary, force = false) => {
11994
if (!environmentId || !threadId) {
@@ -129,8 +104,23 @@ export function useReviewSections(input: {
129104
return;
130105
}
131106

132-
const client = getEnvironmentClient(environmentId);
133-
if (!client) {
107+
const target = {
108+
environmentId,
109+
threadId,
110+
fromTurnCount: Math.max(0, checkpoint.checkpointTurnCount - 1),
111+
toTurnCount: checkpoint.checkpointTurnCount,
112+
ignoreWhitespace: false,
113+
cacheScope: sectionId,
114+
};
115+
const cached = checkpointDiffManager.getSnapshot(target).data;
116+
if (!force && cached) {
117+
if (reviewCache.threadKey) {
118+
setReviewTurnDiff(reviewCache.threadKey, sectionId, cached.diff);
119+
}
120+
return;
121+
}
122+
123+
if (!getEnvironmentClient(environmentId)) {
134124
if (reviewCache.threadKey) {
135125
setReviewAsyncError(reviewCache.threadKey, "Remote connection is not ready.");
136126
}
@@ -142,13 +132,11 @@ export function useReviewSections(input: {
142132
setReviewAsyncError(reviewCache.threadKey, null);
143133
}
144134
try {
145-
const result = await client.orchestration.getTurnDiff({
146-
threadId,
147-
fromTurnCount: Math.max(0, checkpoint.checkpointTurnCount - 1),
148-
toTurnCount: checkpoint.checkpointTurnCount,
149-
});
135+
const result = await loadCheckpointDiff(target, { force });
150136
if (reviewCache.threadKey) {
151-
setReviewTurnDiff(reviewCache.threadKey, sectionId, result.diff);
137+
if (result) {
138+
setReviewTurnDiff(reviewCache.threadKey, sectionId, result.diff);
139+
}
152140
}
153141
} catch (cause) {
154142
if (reviewCache.threadKey) {
@@ -166,10 +154,6 @@ export function useReviewSections(input: {
166154
[environmentId, reviewCache.threadKey, threadId],
167155
);
168156

169-
useEffect(() => {
170-
void loadGitDiffs();
171-
}, [loadGitDiffs]);
172-
173157
useEffect(() => {
174158
if (!hasReviewSections) {
175159
return;
@@ -237,8 +221,8 @@ export function useReviewSections(input: {
237221
return;
238222
}
239223

240-
await loadGitDiffs();
241-
}, [checkpointBySectionId, loadGitDiffs, loadTurnDiff, selectedSection]);
224+
refreshDiffPreview();
225+
}, [checkpointBySectionId, loadTurnDiff, refreshDiffPreview, selectedSection]);
242226

243227
const selectSection = useCallback(
244228
(sectionId: string) => {

apps/mobile/src/features/threads/GitActionProgressOverlay.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
77

88
import { AppText as Text } from "../../components/AppText";
99
import { useThemeColor } from "../../lib/useThemeColor";
10-
import type { GitActionProgress } from "../../state/use-git-action-state";
10+
import type { GitActionProgress } from "../../state/use-vcs-action-state";
1111

1212
export function GitActionProgressOverlay(props: {
1313
readonly progress: GitActionProgress;

apps/mobile/src/features/threads/ThreadRouteScreen.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import { EnvironmentId, type ProjectScript } from "@t3tools/contracts";
77
import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
88
import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native";
99
import { useThemeColor } from "../../lib/useThemeColor";
10-
import { useGitStatus, gitStatusManager } from "../../state/use-git-status";
11-
import { dismissGitActionResult, useGitActionProgress } from "../../state/use-git-action-state";
10+
import { useVcsStatus, vcsStatusManager } from "../../state/use-vcs-status";
11+
import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state";
1212

1313
import { EmptyState } from "../../components/EmptyState";
1414
import { LoadingScreen } from "../../components/LoadingScreen";
@@ -94,7 +94,7 @@ export function ThreadRouteScreen() {
9494
const secondaryFg = isDark ? "#a3a3a3" : "#525252";
9595

9696
/* ─── Git status for native header trigger ───────────────────────── */
97-
const gitStatus = useGitStatus({
97+
const gitStatus = useVcsStatus({
9898
environmentId: selectedThread?.environmentId ?? null,
9999
cwd: selectedThreadCwd,
100100
});
@@ -124,7 +124,7 @@ export function ThreadRouteScreen() {
124124

125125
const handleRefreshGitStatus = useCallback(async () => {
126126
if (!selectedThread) return;
127-
await gitStatusManager.refresh({
127+
await vcsStatusManager.refresh({
128128
environmentId: selectedThread.environmentId,
129129
cwd: selectedThreadCwd,
130130
});

0 commit comments

Comments
 (0)