authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-18 01:06:33-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-18 21:59:17-08:00
log3afbf93d5e54fec6674c62a5521f78c0ec2cf846
tree83fcd8cb80bd6ce1ceb0458d4f8f4ee9dc011f92
parentec5c3c32cd2acaec16f0b6b26d2f0e6bb3030e3c

Use React Query for checkpoint diff loading and caching

- add shared provider checkpoint diff query options and keys - refactor `ChatView` and `DiffPanel` to fetch diffs via React Query - persist selected-turn checkpoint diffs back into thread state for reuse

9 files changed, 258 insertions(+), 194 deletions(-)

apps/server/src/filesystemCheckpointStore.ts+30-8
......@@ -38,7 +38,11 @@ export class FilesystemCheckpointStore {
3838 return result.code === 0 && result.stdout.trim() === "true";
3939 }
4040
41 async captureCheckpoint(input: { cwd: string; threadId: string; turnCount: number }): Promise<void> {
41 async captureCheckpoint(input: {
42 cwd: string;
43 threadId: string;
44 turnCount: number;
45 }): Promise<void> {
4246 const { cwd, threadId, turnCount } = input;
4347 const ref = checkpointRefForThreadTurn(threadId, turnCount);
4448
......@@ -84,7 +88,11 @@ export class FilesystemCheckpointStore {
8488 }
8589 }
8690
87 async hasCheckpoint(input: { cwd: string; threadId: string; turnCount: number }): Promise<boolean> {
91 async hasCheckpoint(input: {
92 cwd: string;
93 threadId: string;
94 turnCount: number;
95 }): Promise<boolean> {
8896 const { cwd, threadId, turnCount } = input;
8997 const ref = checkpointRefForThreadTurn(threadId, turnCount);
9098 const commit = await this.resolveCheckpointCommit(cwd, ref);
......@@ -108,7 +116,11 @@ export class FilesystemCheckpointStore {
108116 return true;
109117 }
110118
111 async restoreCheckpoint(input: { cwd: string; threadId: string; turnCount: number }): Promise<boolean> {
119 async restoreCheckpoint(input: {
120 cwd: string;
121 threadId: string;
122 turnCount: number;
123 }): Promise<boolean> {
112124 const { cwd, threadId, turnCount } = input;
113125 const ref = checkpointRefForThreadTurn(threadId, turnCount);
114126 let commitOid = await this.resolveCheckpointCommit(cwd, ref);
......@@ -158,7 +170,9 @@ export class FilesystemCheckpointStore {
158170 );
159171 }
160172 if (!toCommitOid) {
161 throw new Error(`Filesystem checkpoint is unavailable for turn ${toTurnCount} in thread ${threadId}.`);
173 throw new Error(
174 `Filesystem checkpoint is unavailable for turn ${toTurnCount} in thread ${threadId}.`,
175 );
162176 }
163177
164178 const result = await this.runGit(cwd, [
......@@ -183,16 +197,24 @@ export class FilesystemCheckpointStore {
183197 return commit.length > 0 ? commit : null;
184198 }
185199
186 async pruneAfterTurn(input: { cwd: string; threadId: string; maxTurnCount: number }): Promise<void> {
200 async pruneAfterTurn(input: {
201 cwd: string;
202 threadId: string;
203 maxTurnCount: number;
204 }): Promise<void> {
187205 const { cwd, threadId, maxTurnCount } = input;
188206 if (!Number.isInteger(maxTurnCount) || maxTurnCount < 0) {
189207 throw new Error(`Invalid max turn count: ${maxTurnCount}`);
190208 }
191209
192210 const threadRefPrefix = `${CHECKPOINT_REFS_PREFIX}/${checkpointRefThreadSegment(threadId)}/turn/`;
193 const result = await this.runGit(cwd, ["for-each-ref", "--format=%(refname)", threadRefPrefix], {
194 allowNonZeroExit: true,
195 });
211 const result = await this.runGit(
212 cwd,
213 ["for-each-ref", "--format=%(refname)", threadRefPrefix],
214 {
215 allowNonZeroExit: true,
216 },
217 );
196218 if (result.code !== 0) {
197219 return;
198220 }
apps/server/src/providerManager.test.ts+49-11
......@@ -159,7 +159,10 @@ describe("ProviderManager", () => {
159159 threadId: string;
160160 turns: Array<{ id: string; items: unknown[] }>;
161161 }>;
162 rollbackThread: (sessionId: string, numTurns: number) => Promise<{
162 rollbackThread: (
163 sessionId: string,
164 numTurns: number,
165 ) => Promise<{
163166 threadId: string;
164167 turns: Array<{ id: string; items: unknown[] }>;
165168 }>;
......@@ -253,7 +256,10 @@ describe("ProviderManager", () => {
253256 internals.codex.hasSession = () => true;
254257 internals.codex.readThread = async () => ({
255258 threadId: "thr_1",
256 turns: [{ id: "turn_1", items: [] }, { id: "turn_2", items: [] }],
259 turns: [
260 { id: "turn_1", items: [] },
261 { id: "turn_2", items: [] },
262 ],
257263 });
258264 const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts");
259265 internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints;
......@@ -330,7 +336,10 @@ describe("ProviderManager", () => {
330336 ];
331337 internals.codex.readThread = async () => ({
332338 threadId: "thr_1",
333 turns: [{ id: "turn_1", items: [] }, { id: "turn_2", items: [] }],
339 turns: [
340 { id: "turn_1", items: [] },
341 { id: "turn_2", items: [] },
342 ],
334343 });
335344 const isGitRepository = vi.fn(async () => true);
336345 const ensureRootCheckpoint = vi.fn(async () => true);
......@@ -471,19 +480,30 @@ describe("ProviderManager", () => {
471480 threadId: string;
472481 turns: Array<{ id: string; items: unknown[] }>;
473482 }>;
474 rollbackThread: (sessionId: string, numTurns: number) => Promise<{
483 rollbackThread: (
484 sessionId: string,
485 numTurns: number,
486 ) => Promise<{
475487 threadId: string;
476488 turns: Array<{ id: string; items: unknown[] }>;
477489 }>;
478490 };
479491 filesystemCheckpointStore: {
480 hasCheckpoint: (input: { cwd: string; threadId: string; turnCount: number }) => Promise<boolean>;
492 hasCheckpoint: (input: {
493 cwd: string;
494 threadId: string;
495 turnCount: number;
496 }) => Promise<boolean>;
481497 restoreCheckpoint: (input: {
482498 cwd: string;
483499 threadId: string;
484500 turnCount: number;
485501 }) => Promise<boolean>;
486 pruneAfterTurn: (input: { cwd: string; threadId: string; maxTurnCount: number }) => Promise<void>;
502 pruneAfterTurn: (input: {
503 cwd: string;
504 threadId: string;
505 maxTurnCount: number;
506 }) => Promise<void>;
487507 };
488508 sessionCheckpointCwds: Map<string, string>;
489509 };
......@@ -542,19 +562,30 @@ describe("ProviderManager", () => {
542562 threadId: string;
543563 turns: Array<{ id: string; items: unknown[] }>;
544564 }>;
545 rollbackThread: (sessionId: string, numTurns: number) => Promise<{
565 rollbackThread: (
566 sessionId: string,
567 numTurns: number,
568 ) => Promise<{
546569 threadId: string;
547570 turns: Array<{ id: string; items: unknown[] }>;
548571 }>;
549572 };
550573 filesystemCheckpointStore: {
551 hasCheckpoint: (input: { cwd: string; threadId: string; turnCount: number }) => Promise<boolean>;
574 hasCheckpoint: (input: {
575 cwd: string;
576 threadId: string;
577 turnCount: number;
578 }) => Promise<boolean>;
552579 restoreCheckpoint: (input: {
553580 cwd: string;
554581 threadId: string;
555582 turnCount: number;
556583 }) => Promise<boolean>;
557 pruneAfterTurn: (input: { cwd: string; threadId: string; maxTurnCount: number }) => Promise<void>;
584 pruneAfterTurn: (input: {
585 cwd: string;
586 threadId: string;
587 maxTurnCount: number;
588 }) => Promise<void>;
558589 };
559590 sessionCheckpointCwds: Map<string, string>;
560591 };
......@@ -609,13 +640,20 @@ describe("ProviderManager", () => {
609640 threadId: string;
610641 turns: Array<{ id: string; items: unknown[] }>;
611642 }>;
612 rollbackThread: (sessionId: string, numTurns: number) => Promise<{
643 rollbackThread: (
644 sessionId: string,
645 numTurns: number,
646 ) => Promise<{
613647 threadId: string;
614648 turns: Array<{ id: string; items: unknown[] }>;
615649 }>;
616650 };
617651 filesystemCheckpointStore: {
618 hasCheckpoint: (input: { cwd: string; threadId: string; turnCount: number }) => Promise<boolean>;
652 hasCheckpoint: (input: {
653 cwd: string;
654 threadId: string;
655 turnCount: number;
656 }) => Promise<boolean>;
619657 };
620658 sessionCheckpointCwds: Map<string, string>;
621659 };
apps/server/src/providerManager.ts+3-1
......@@ -425,7 +425,9 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
425425 return existingCwd;
426426 }
427427
428 const session = this.codex.listSessions().find((candidate) => candidate.sessionId === sessionId);
428 const session = this.codex
429 .listSessions()
430 .find((candidate) => candidate.sessionId === sessionId);
429431 const candidateCwds = Array.from(
430432 new Set([session?.cwd, process.cwd()].filter((value): value is string => Boolean(value))),
431433 );
apps/web/src/components/ChatView.tsx+52-52
......@@ -27,10 +27,11 @@ import {
2727 useRef,
2828 useState,
2929} from "react";
30import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
30import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
3131import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
3232import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
3333import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
34import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
3435import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
3536
3637import { isElectron } from "../env";
......@@ -373,12 +374,11 @@ export default function ChatView() {
373374 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
374375 const dragDepthRef = useRef(0);
375376 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
376 const checkpointDiffRequestsRef = useRef(new Set<string>());
377377 const checkpointHydrationSessionRequestRef = useRef(new Set<string>());
378378
379379 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
380380 const activeThreadId = activeThread?.id ?? null;
381 const activeSessionId = activeThread?.session?.sessionId;
381 const activeSessionId = activeThread?.session?.sessionId ?? null;
382382 const activeThreadRuntimeId =
383383 activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null;
384384 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
......@@ -433,6 +433,7 @@ export default function ChatView() {
433433 () => inferCheckpointTurnCountByTurnId(turnDiffSummaries),
434434 [turnDiffSummaries],
435435 );
436
436437 const revertTurnCountByUserMessageId = useMemo(() => {
437438 const byUserMessageId = new Map<string, number>();
438439 for (let index = 0; index < timelineEntries.length; index += 1) {
......@@ -496,68 +497,67 @@ export default function ChatView() {
496497 });
497498 }, [activeThreadId, dispatch, inferredCheckpointTurnCountByTurnId, turnDiffSummaries]);
498499
499 useEffect(() => {
500 const checkpointDiffHydrationTargets = useMemo(() => {
500501 if (!api || !activeThreadId || !activeSessionId || turnDiffSummaries.length === 0) {
501 return;
502 return [];
502503 }
503
504 const turnSummariesNeedingDiff = turnDiffSummaries.filter(
505 (summary) =>
506 !summary.checkpointDiffLoaded && inferredCheckpointTurnCountByTurnId[summary.turnId],
507 );
508 const requestedSummaries = turnSummariesNeedingDiff.filter((summary) => {
509 const requestKey = `${activeThreadId}:${summary.turnId}`;
510 if (checkpointDiffRequestsRef.current.has(requestKey)) {
511 return false;
504 return turnDiffSummaries.flatMap((summary) => {
505 const turnCount = inferredCheckpointTurnCountByTurnId[summary.turnId];
506 if (summary.checkpointDiffLoaded || typeof turnCount !== "number") {
507 return [];
512508 }
513 checkpointDiffRequestsRef.current.add(requestKey);
514 return true;
515 });
516 if (requestedSummaries.length === 0) {
517 return;
518 }
519
520 let cancelled = false;
521 void Promise.all(
522 requestedSummaries.map(async (summary) => {
523 const turnCount = inferredCheckpointTurnCountByTurnId[summary.turnId] ?? 0;
524 const result = await api.providers.getCheckpointDiff({
525 sessionId: activeSessionId,
509 return [
510 {
511 turnId: summary.turnId,
526512 fromTurnCount: Math.max(0, turnCount - 1),
527513 toTurnCount: turnCount,
528 });
529 return [summary.turnId, result.diff] as const;
530 }),
531 )
532 .then((entries) => {
533 if (cancelled) return;
534 dispatch({
535 type: "SET_THREAD_TURN_CHECKPOINT_DIFFS",
536 threadId: activeThreadId,
537 checkpointDiffByTurnId: Object.fromEntries(entries),
538 });
539 })
540 .catch(() => {
541 if (cancelled) return;
542 })
543 .finally(() => {
544 for (const summary of requestedSummaries) {
545 checkpointDiffRequestsRef.current.delete(`${activeThreadId}:${summary.turnId}`);
546 }
547 });
548
549 return () => {
550 cancelled = true;
551 };
514 },
515 ];
516 });
552517 }, [
553518 activeSessionId,
554519 activeThreadId,
555520 api,
556 dispatch,
557521 inferredCheckpointTurnCountByTurnId,
558522 turnDiffSummaries,
559523 ]);
560524
525 const checkpointDiffHydrationQueries = useQueries({
526 queries: checkpointDiffHydrationTargets.map((target) =>
527 checkpointDiffQueryOptions(api, {
528 sessionId: activeSessionId,
529 threadRuntimeId: activeThreadRuntimeId,
530 fromTurnCount: target.fromTurnCount,
531 toTurnCount: target.toTurnCount,
532 }),
533 ),
534 });
535
536 useEffect(() => {
537 if (!activeThreadId || checkpointDiffHydrationTargets.length === 0) {
538 return;
539 }
540
541 const checkpointDiffByTurnId: Record<string, string> = {};
542 for (let index = 0; index < checkpointDiffHydrationTargets.length; index += 1) {
543 const target = checkpointDiffHydrationTargets[index];
544 const query = checkpointDiffHydrationQueries[index];
545 const diff = query?.data?.diff;
546 if (!target || !diff) {
547 continue;
548 }
549 checkpointDiffByTurnId[target.turnId] = diff;
550 }
551 if (Object.keys(checkpointDiffByTurnId).length === 0) {
552 return;
553 }
554 dispatch({
555 type: "SET_THREAD_TURN_CHECKPOINT_DIFFS",
556 threadId: activeThreadId,
557 checkpointDiffByTurnId,
558 });
559 }, [activeThreadId, checkpointDiffHydrationQueries, checkpointDiffHydrationTargets, dispatch]);
560
561561 const completionSummary = useMemo(() => {
562562 if (!activeThread?.latestTurnStartedAt) return null;
563563 if (!activeThread.latestTurnCompletedAt) return null;
apps/web/src/components/DiffPanel.tsx+58-119
......@@ -1,7 +1,10 @@
11import { parsePatchFiles } from "@pierre/diffs";
22import { FileDiff, type FileDiffMetadata } from "@pierre/diffs/react";
3import { Columns2Icon, Rows3Icon, XIcon } from "lucide-react";
3import { useQuery, useQueryClient } from "@tanstack/react-query";
4import { Columns2Icon, Rows3Icon } from "lucide-react";
45import { useEffect, useMemo, useRef, useState } from "react";
6import { checkpointDiffQueryOptions, providerQueryKeys } from "~/lib/providerReactQuery";
7import { cn } from "~/lib/utils";
58import { isElectron } from "../env";
69import { useNativeApi } from "../hooks/useNativeApi";
710import { useTheme } from "../hooks/useTheme";
......@@ -11,9 +14,6 @@ import {
1114 inferCheckpointTurnCountByTurnId,
1215} from "../session-logic";
1316import { useStore } from "../store";
14import { Button } from "./ui/button";
15import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
16import { cn } from "~/lib/utils";
1717import { ToggleGroup, Toggle } from "./ui/toggle-group";
1818
1919type DiffRenderMode = "stacked" | "split";
......@@ -63,14 +63,6 @@ function resolveFileDiffPath(fileDiff: FileDiffMetadata): string {
6363 return raw;
6464}
6565
66function buildCheckpointDiffKey(
67 sessionId: string,
68 fromTurnCount: number,
69 toTurnCount: number,
70): string {
71 return `${sessionId}:${fromTurnCount}-${toTurnCount}`;
72}
73
7466interface DiffPanelProps {
7567 mode?: "inline" | "sheet";
7668}
......@@ -79,12 +71,13 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
7971 const api = useNativeApi();
8072 const { resolvedTheme } = useTheme();
8173 const { state, dispatch } = useStore();
82 const [checkpointDiffByKey, setCheckpointDiffByKey] = useState<Record<string, string>>({});
83 const [isLoadingCheckpointDiff, setIsLoadingCheckpointDiff] = useState(false);
84 const [checkpointDiffError, setCheckpointDiffError] = useState<string | null>(null);
74 const queryClient = useQueryClient();
8575 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
8676 const patchViewportRef = useRef<HTMLDivElement>(null);
8777 const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId);
78 const activeThreadRuntimeId =
79 activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null;
80 const activeSessionId = activeThread?.session?.sessionId ?? null;
8881 const turnDiffSummaries = useMemo(
8982 () =>
9083 activeThread?.turnDiffSummaries.length
......@@ -97,11 +90,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
9790 [turnDiffSummaries],
9891 );
9992
100 useEffect(() => {
101 setCheckpointDiffByKey({});
102 setCheckpointDiffError(null);
103 }, [activeThread?.id]);
104
10593 const canApplyStoredTarget = Boolean(activeThread && state.diffThreadId === activeThread.id);
10694 const selectedTurnId = canApplyStoredTarget ? state.diffTurnId : null;
10795 const selectedFilePath =
......@@ -147,41 +135,57 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
147135 : null,
148136 [conversationCheckpointTurnCount, selectedTurn],
149137 );
150 const selectedCheckpointDiffKey =
151 activeThread?.session?.sessionId && selectedCheckpointRange
152 ? buildCheckpointDiffKey(
153 activeThread.session.sessionId,
154 selectedCheckpointRange.fromTurnCount,
155 selectedCheckpointRange.toTurnCount,
156 )
157 : null;
158 const conversationCheckpointDiffKey =
159 activeThread?.session?.sessionId && conversationCheckpointRange
160 ? buildCheckpointDiffKey(
161 activeThread.session.sessionId,
162 conversationCheckpointRange.fromTurnCount,
163 conversationCheckpointRange.toTurnCount,
164 )
165 : null;
166 const selectedTurnCheckpointDiff =
167 selectedCheckpointDiffKey === null ? undefined : checkpointDiffByKey[selectedCheckpointDiffKey];
168 const conversationCheckpointDiff =
169 conversationCheckpointDiffKey === null
170 ? undefined
171 : checkpointDiffByKey[conversationCheckpointDiffKey];
138 const activeCheckpointRange = selectedTurn
139 ? selectedCheckpointRange
140 : conversationCheckpointRange;
141 const activeCheckpointDiffQuery = useQuery(
142 checkpointDiffQueryOptions(api, {
143 sessionId: activeSessionId,
144 threadRuntimeId: activeThreadRuntimeId,
145 fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,
146 toTurnCount: activeCheckpointRange?.toTurnCount ?? null,
147 }),
148 );
149 const selectedTurnCheckpointDiff = selectedTurn
150 ? activeCheckpointDiffQuery.data?.diff
151 : undefined;
152 const conversationCheckpointDiff = selectedTurn
153 ? undefined
154 : activeCheckpointDiffQuery.data?.diff;
155 const isLoadingCheckpointDiff = activeCheckpointDiffQuery.isPending;
156 const checkpointDiffError =
157 activeCheckpointDiffQuery.error instanceof Error
158 ? activeCheckpointDiffQuery.error.message
159 : activeCheckpointDiffQuery.error
160 ? "Failed to load checkpoint diff."
161 : null;
162
163 useEffect(() => {
164 if (!activeThread?.id || !selectedTurn || !selectedTurnCheckpointDiff) {
165 return;
166 }
167 dispatch({
168 type: "SET_THREAD_TURN_CHECKPOINT_DIFFS",
169 threadId: activeThread.id,
170 checkpointDiffByTurnId: {
171 [selectedTurn.turnId]: selectedTurnCheckpointDiff,
172 },
173 });
174 }, [activeThread?.id, dispatch, selectedTurn, selectedTurnCheckpointDiff]);
175
172176 const selectedPatch = useMemo(() => {
173177 const patchForSummary = (summary: (typeof turnDiffSummaries)[number]): string | undefined => {
174178 const checkpointTurnCount =
175179 summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId];
176 if (activeThread?.session?.sessionId && typeof checkpointTurnCount === "number") {
177 const checkpointPatch =
178 checkpointDiffByKey[
179 buildCheckpointDiffKey(
180 activeThread.session.sessionId,
181 Math.max(0, checkpointTurnCount - 1),
182 checkpointTurnCount,
183 )
184 ];
180 if (activeSessionId && typeof checkpointTurnCount === "number") {
181 const checkpointPatch = queryClient.getQueryData<{ diff: string }>(
182 providerQueryKeys.checkpointDiff({
183 sessionId: activeSessionId,
184 threadRuntimeId: activeThreadRuntimeId,
185 fromTurnCount: Math.max(0, checkpointTurnCount - 1),
186 toTurnCount: checkpointTurnCount,
187 }),
188 )?.diff;
185189 if (checkpointPatch) {
186190 return checkpointPatch;
187191 }
......@@ -239,10 +243,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
239243 }
240244 return patches.join("\n\n");
241245 }, [
242 activeThread?.session?.sessionId,
243 checkpointDiffByKey,
246 activeSessionId,
247 activeThreadRuntimeId,
244248 conversationCheckpointDiff,
245249 inferredCheckpointTurnCountByTurnId,
250 queryClient,
246251 selectedTurn,
247252 selectedTurnCheckpointDiff,
248253 turnDiffSummaries,
......@@ -260,72 +265,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
260265 );
261266 }, [renderablePatch]);
262267
263 useEffect(() => {
264 const checkpointFetchRange = selectedTurn
265 ? selectedCheckpointRange
266 : conversationCheckpointRange;
267 const checkpointFetchKey = selectedTurn
268 ? selectedCheckpointDiffKey
269 : conversationCheckpointDiffKey;
270 if (!api || !activeThread?.session?.sessionId || !checkpointFetchRange || !checkpointFetchKey) {
271 return;
272 }
273 if (checkpointDiffByKey[checkpointFetchKey] !== undefined) {
274 return;
275 }
276
277 let cancelled = false;
278 setIsLoadingCheckpointDiff(true);
279 setCheckpointDiffError(null);
280 void api.providers
281 .getCheckpointDiff({
282 sessionId: activeThread.session.sessionId,
283 fromTurnCount: checkpointFetchRange.fromTurnCount,
284 toTurnCount: checkpointFetchRange.toTurnCount,
285 })
286 .then((result) => {
287 if (cancelled) return;
288 setCheckpointDiffByKey((previous) => ({
289 ...previous,
290 [checkpointFetchKey]: result.diff,
291 }));
292 if (selectedTurn) {
293 dispatch({
294 type: "SET_THREAD_TURN_CHECKPOINT_DIFFS",
295 threadId: activeThread.id,
296 checkpointDiffByTurnId: {
297 [selectedTurn.turnId]: result.diff,
298 },
299 });
300 }
301 })
302 .catch((error) => {
303 if (cancelled) return;
304 setCheckpointDiffError(
305 error instanceof Error ? error.message : "Failed to load checkpoint diff.",
306 );
307 })
308 .finally(() => {
309 if (cancelled) return;
310 setIsLoadingCheckpointDiff(false);
311 });
312
313 return () => {
314 cancelled = true;
315 };
316 }, [
317 activeThread?.id,
318 activeThread?.session?.sessionId,
319 api,
320 checkpointDiffByKey,
321 conversationCheckpointDiffKey,
322 conversationCheckpointRange,
323 dispatch,
324 selectedCheckpointDiffKey,
325 selectedCheckpointRange,
326 selectedTurn,
327 ]);
328
329268 useEffect(() => {
330269 if (!selectedFilePath || !patchViewportRef.current) {
331270 return;
apps/web/src/lib/providerReactQuery.ts created+52
......@@ -0,0 +1,52 @@
1import type { NativeApi } from "@t3tools/contracts";
2import { queryOptions } from "@tanstack/react-query";
3
4export interface CheckpointDiffQueryInput {
5 sessionId: string | null;
6 threadRuntimeId: string | null;
7 fromTurnCount: number | null;
8 toTurnCount: number | null;
9}
10
11export const providerQueryKeys = {
12 all: ["providers"] as const,
13 checkpointDiff: (input: CheckpointDiffQueryInput) =>
14 [
15 "providers",
16 "checkpointDiff",
17 input.sessionId,
18 input.threadRuntimeId,
19 input.fromTurnCount,
20 input.toTurnCount,
21 ] as const,
22};
23
24export function checkpointDiffQueryOptions(
25 api: NativeApi | undefined,
26 input: CheckpointDiffQueryInput,
27) {
28 const hasValidRange =
29 typeof input.fromTurnCount === "number" && typeof input.toTurnCount === "number";
30
31 return queryOptions({
32 queryKey: providerQueryKeys.checkpointDiff(input),
33 queryFn: async () => {
34 if (!api || !input.sessionId || !hasValidRange) {
35 throw new Error("Checkpoint diff is unavailable.");
36 }
37 const { fromTurnCount, toTurnCount } = input;
38 if (typeof fromTurnCount !== "number" || typeof toTurnCount !== "number") {
39 throw new Error("Checkpoint diff range is invalid.");
40 }
41 return api.providers.getCheckpointDiff({
42 sessionId: input.sessionId,
43 fromTurnCount,
44 toTurnCount,
45 });
46 },
47 enabled: !!api && !!input.sessionId && hasValidRange,
48 staleTime: Infinity,
49 retry: 8,
50 retryDelay: (attempt) => Math.min(2_000, 150 * 2 ** (attempt - 1)),
51 });
52}
apps/web/src/store.test.ts+6-1
......@@ -880,7 +880,12 @@ describe("store reducer thread continuity", () => {
880880 {
881881 turnId: "turn-1",
882882 completedAt: "2026-02-09T00:00:03.000Z",
883 files: [{ path: "src/from-checkpoint.ts", diff: "diff --git a/src/from-checkpoint.ts b/src/from-checkpoint.ts" }],
883 files: [
884 {
885 path: "src/from-checkpoint.ts",
886 diff: "diff --git a/src/from-checkpoint.ts b/src/from-checkpoint.ts",
887 },
888 ],
884889 unifiedDiff: "diff --git a/src/from-checkpoint.ts b/src/from-checkpoint.ts",
885890 checkpointDiffLoaded: true,
886891 },
apps/web/src/store.ts+5-1
......@@ -1075,7 +1075,11 @@ export function reducer(state: AppState, action: Action): AppState {
10751075 existing.deletions === file.deletions
10761076 );
10771077 });
1078 if (summary.unifiedDiff === checkpointDiff && filesUnchanged && summary.checkpointDiffLoaded) {
1078 if (
1079 summary.unifiedDiff === checkpointDiff &&
1080 filesUnchanged &&
1081 summary.checkpointDiffLoaded
1082 ) {
10791083 return summary;
10801084 }
10811085
packages/contracts/src/ipc.ts+3-1
......@@ -95,7 +95,9 @@ export interface NativeApi {
9595 respondToRequest: (input: ProviderRespondToRequestInput) => Promise<void>;
9696 stopSession: (input: ProviderStopSessionInput) => Promise<void>;
9797 listSessions: () => Promise<ProviderSession[]>;
98 listCheckpoints: (input: ProviderListCheckpointsInput) => Promise<ProviderListCheckpointsResult>;
98 listCheckpoints: (
99 input: ProviderListCheckpointsInput,
100 ) => Promise<ProviderListCheckpointsResult>;
99101 getCheckpointDiff: (
100102 input: ProviderGetCheckpointDiffInput,
101103 ) => Promise<ProviderGetCheckpointDiffResult>;