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 {...@@ -38,7 +38,11 @@ export class FilesystemCheckpointStore {
38 return result.code === 0 && result.stdout.trim() === "true";38 return result.code === 0 && result.stdout.trim() === "true";
39 }39 }
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> {
42 const { cwd, threadId, turnCount } = input;46 const { cwd, threadId, turnCount } = input;
43 const ref = checkpointRefForThreadTurn(threadId, turnCount);47 const ref = checkpointRefForThreadTurn(threadId, turnCount);
4448
...@@ -84,7 +88,11 @@ export class FilesystemCheckpointStore {...@@ -84,7 +88,11 @@ export class FilesystemCheckpointStore {
84 }88 }
85 }89 }
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> {
88 const { cwd, threadId, turnCount } = input;96 const { cwd, threadId, turnCount } = input;
89 const ref = checkpointRefForThreadTurn(threadId, turnCount);97 const ref = checkpointRefForThreadTurn(threadId, turnCount);
90 const commit = await this.resolveCheckpointCommit(cwd, ref);98 const commit = await this.resolveCheckpointCommit(cwd, ref);
...@@ -108,7 +116,11 @@ export class FilesystemCheckpointStore {...@@ -108,7 +116,11 @@ export class FilesystemCheckpointStore {
108 return true;116 return true;
109 }117 }
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> {
112 const { cwd, threadId, turnCount } = input;124 const { cwd, threadId, turnCount } = input;
113 const ref = checkpointRefForThreadTurn(threadId, turnCount);125 const ref = checkpointRefForThreadTurn(threadId, turnCount);
114 let commitOid = await this.resolveCheckpointCommit(cwd, ref);126 let commitOid = await this.resolveCheckpointCommit(cwd, ref);
...@@ -158,7 +170,9 @@ export class FilesystemCheckpointStore {...@@ -158,7 +170,9 @@ export class FilesystemCheckpointStore {
158 );170 );
159 }171 }
160 if (!toCommitOid) {172 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 );
162 }176 }
163177
164 const result = await this.runGit(cwd, [178 const result = await this.runGit(cwd, [
...@@ -183,16 +197,24 @@ export class FilesystemCheckpointStore {...@@ -183,16 +197,24 @@ export class FilesystemCheckpointStore {
183 return commit.length > 0 ? commit : null;197 return commit.length > 0 ? commit : null;
184 }198 }
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> {
187 const { cwd, threadId, maxTurnCount } = input;205 const { cwd, threadId, maxTurnCount } = input;
188 if (!Number.isInteger(maxTurnCount) || maxTurnCount < 0) {206 if (!Number.isInteger(maxTurnCount) || maxTurnCount < 0) {
189 throw new Error(`Invalid max turn count: ${maxTurnCount}`);207 throw new Error(`Invalid max turn count: ${maxTurnCount}`);
190 }208 }
191209
192 const threadRefPrefix = `${CHECKPOINT_REFS_PREFIX}/${checkpointRefThreadSegment(threadId)}/turn/`;210 const threadRefPrefix = `${CHECKPOINT_REFS_PREFIX}/${checkpointRefThreadSegment(threadId)}/turn/`;
193 const result = await this.runGit(cwd, ["for-each-ref", "--format=%(refname)", threadRefPrefix], {211 const result = await this.runGit(
194 allowNonZeroExit: true,212 cwd,
195 });213 ["for-each-ref", "--format=%(refname)", threadRefPrefix],
214 {
215 allowNonZeroExit: true,
216 },
217 );
196 if (result.code !== 0) {218 if (result.code !== 0) {
197 return;219 return;
198 }220 }
apps/server/src/providerManager.test.ts+49-11
...@@ -159,7 +159,10 @@ describe("ProviderManager", () => {...@@ -159,7 +159,10 @@ describe("ProviderManager", () => {
159 threadId: string;159 threadId: string;
160 turns: Array<{ id: string; items: unknown[] }>;160 turns: Array<{ id: string; items: unknown[] }>;
161 }>;161 }>;
162 rollbackThread: (sessionId: string, numTurns: number) => Promise<{162 rollbackThread: (
163 sessionId: string,
164 numTurns: number,
165 ) => Promise<{
163 threadId: string;166 threadId: string;
164 turns: Array<{ id: string; items: unknown[] }>;167 turns: Array<{ id: string; items: unknown[] }>;
165 }>;168 }>;
...@@ -253,7 +256,10 @@ describe("ProviderManager", () => {...@@ -253,7 +256,10 @@ describe("ProviderManager", () => {
253 internals.codex.hasSession = () => true;256 internals.codex.hasSession = () => true;
254 internals.codex.readThread = async () => ({257 internals.codex.readThread = async () => ({
255 threadId: "thr_1",258 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 ],
257 });263 });
258 const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts");264 const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts");
259 internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints;265 internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints;
...@@ -330,7 +336,10 @@ describe("ProviderManager", () => {...@@ -330,7 +336,10 @@ describe("ProviderManager", () => {
330 ];336 ];
331 internals.codex.readThread = async () => ({337 internals.codex.readThread = async () => ({
332 threadId: "thr_1",338 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 ],
334 });343 });
335 const isGitRepository = vi.fn(async () => true);344 const isGitRepository = vi.fn(async () => true);
336 const ensureRootCheckpoint = vi.fn(async () => true);345 const ensureRootCheckpoint = vi.fn(async () => true);
...@@ -471,19 +480,30 @@ describe("ProviderManager", () => {...@@ -471,19 +480,30 @@ describe("ProviderManager", () => {
471 threadId: string;480 threadId: string;
472 turns: Array<{ id: string; items: unknown[] }>;481 turns: Array<{ id: string; items: unknown[] }>;
473 }>;482 }>;
474 rollbackThread: (sessionId: string, numTurns: number) => Promise<{483 rollbackThread: (
484 sessionId: string,
485 numTurns: number,
486 ) => Promise<{
475 threadId: string;487 threadId: string;
476 turns: Array<{ id: string; items: unknown[] }>;488 turns: Array<{ id: string; items: unknown[] }>;
477 }>;489 }>;
478 };490 };
479 filesystemCheckpointStore: {491 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>;
481 restoreCheckpoint: (input: {497 restoreCheckpoint: (input: {
482 cwd: string;498 cwd: string;
483 threadId: string;499 threadId: string;
484 turnCount: number;500 turnCount: number;
485 }) => Promise<boolean>;501 }) => 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>;
487 };507 };
488 sessionCheckpointCwds: Map<string, string>;508 sessionCheckpointCwds: Map<string, string>;
489 };509 };
...@@ -542,19 +562,30 @@ describe("ProviderManager", () => {...@@ -542,19 +562,30 @@ describe("ProviderManager", () => {
542 threadId: string;562 threadId: string;
543 turns: Array<{ id: string; items: unknown[] }>;563 turns: Array<{ id: string; items: unknown[] }>;
544 }>;564 }>;
545 rollbackThread: (sessionId: string, numTurns: number) => Promise<{565 rollbackThread: (
566 sessionId: string,
567 numTurns: number,
568 ) => Promise<{
546 threadId: string;569 threadId: string;
547 turns: Array<{ id: string; items: unknown[] }>;570 turns: Array<{ id: string; items: unknown[] }>;
548 }>;571 }>;
549 };572 };
550 filesystemCheckpointStore: {573 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>;
552 restoreCheckpoint: (input: {579 restoreCheckpoint: (input: {
553 cwd: string;580 cwd: string;
554 threadId: string;581 threadId: string;
555 turnCount: number;582 turnCount: number;
556 }) => Promise<boolean>;583 }) => 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>;
558 };589 };
559 sessionCheckpointCwds: Map<string, string>;590 sessionCheckpointCwds: Map<string, string>;
560 };591 };
...@@ -609,13 +640,20 @@ describe("ProviderManager", () => {...@@ -609,13 +640,20 @@ describe("ProviderManager", () => {
609 threadId: string;640 threadId: string;
610 turns: Array<{ id: string; items: unknown[] }>;641 turns: Array<{ id: string; items: unknown[] }>;
611 }>;642 }>;
612 rollbackThread: (sessionId: string, numTurns: number) => Promise<{643 rollbackThread: (
644 sessionId: string,
645 numTurns: number,
646 ) => Promise<{
613 threadId: string;647 threadId: string;
614 turns: Array<{ id: string; items: unknown[] }>;648 turns: Array<{ id: string; items: unknown[] }>;
615 }>;649 }>;
616 };650 };
617 filesystemCheckpointStore: {651 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>;
619 };657 };
620 sessionCheckpointCwds: Map<string, string>;658 sessionCheckpointCwds: Map<string, string>;
621 };659 };
apps/server/src/providerManager.ts+3-1
...@@ -425,7 +425,9 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {...@@ -425,7 +425,9 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
425 return existingCwd;425 return existingCwd;
426 }426 }
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);
429 const candidateCwds = Array.from(431 const candidateCwds = Array.from(
430 new Set([session?.cwd, process.cwd()].filter((value): value is string => Boolean(value))),432 new Set([session?.cwd, process.cwd()].filter((value): value is string => Boolean(value))),
431 );433 );
apps/web/src/components/ChatView.tsx+52-52
...@@ -27,10 +27,11 @@ import {...@@ -27,10 +27,11 @@ import {
27 useRef,27 useRef,
28 useState,28 useState,
29} from "react";29} from "react";
30import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";30import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
31import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";31import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
32import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";32import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
33import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";33import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
34import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
34import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";35import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
3536
36import { isElectron } from "../env";37import { isElectron } from "../env";
...@@ -373,12 +374,11 @@ export default function ChatView() {...@@ -373,12 +374,11 @@ export default function ChatView() {
373 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);374 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
374 const dragDepthRef = useRef(0);375 const dragDepthRef = useRef(0);
375 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});376 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
376 const checkpointDiffRequestsRef = useRef(new Set<string>());
377 const checkpointHydrationSessionRequestRef = useRef(new Set<string>());377 const checkpointHydrationSessionRequestRef = useRef(new Set<string>());
378378
379 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);379 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
380 const activeThreadId = activeThread?.id ?? null;380 const activeThreadId = activeThread?.id ?? null;
381 const activeSessionId = activeThread?.session?.sessionId;381 const activeSessionId = activeThread?.session?.sessionId ?? null;
382 const activeThreadRuntimeId =382 const activeThreadRuntimeId =
383 activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null;383 activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null;
384 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);384 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
...@@ -433,6 +433,7 @@ export default function ChatView() {...@@ -433,6 +433,7 @@ export default function ChatView() {
433 () => inferCheckpointTurnCountByTurnId(turnDiffSummaries),433 () => inferCheckpointTurnCountByTurnId(turnDiffSummaries),
434 [turnDiffSummaries],434 [turnDiffSummaries],
435 );435 );
436
436 const revertTurnCountByUserMessageId = useMemo(() => {437 const revertTurnCountByUserMessageId = useMemo(() => {
437 const byUserMessageId = new Map<string, number>();438 const byUserMessageId = new Map<string, number>();
438 for (let index = 0; index < timelineEntries.length; index += 1) {439 for (let index = 0; index < timelineEntries.length; index += 1) {
...@@ -496,68 +497,67 @@ export default function ChatView() {...@@ -496,68 +497,67 @@ export default function ChatView() {
496 });497 });
497 }, [activeThreadId, dispatch, inferredCheckpointTurnCountByTurnId, turnDiffSummaries]);498 }, [activeThreadId, dispatch, inferredCheckpointTurnCountByTurnId, turnDiffSummaries]);
498499
499 useEffect(() => {500 const checkpointDiffHydrationTargets = useMemo(() => {
500 if (!api || !activeThreadId || !activeSessionId || turnDiffSummaries.length === 0) {501 if (!api || !activeThreadId || !activeSessionId || turnDiffSummaries.length === 0) {
501 return;502 return [];
502 }503 }
503504 return turnDiffSummaries.flatMap((summary) => {
504 const turnSummariesNeedingDiff = turnDiffSummaries.filter(505 const turnCount = inferredCheckpointTurnCountByTurnId[summary.turnId];
505 (summary) =>506 if (summary.checkpointDiffLoaded || typeof turnCount !== "number") {
506 !summary.checkpointDiffLoaded && inferredCheckpointTurnCountByTurnId[summary.turnId],507 return [];
507 );
508 const requestedSummaries = turnSummariesNeedingDiff.filter((summary) => {
509 const requestKey = `${activeThreadId}:${summary.turnId}`;
510 if (checkpointDiffRequestsRef.current.has(requestKey)) {
511 return false;
512 }508 }
513 checkpointDiffRequestsRef.current.add(requestKey);509 return [
514 return true;510 {
515 });511 turnId: summary.turnId,
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,
526 fromTurnCount: Math.max(0, turnCount - 1),512 fromTurnCount: Math.max(0, turnCount - 1),
527 toTurnCount: turnCount,513 toTurnCount: turnCount,
528 });514 },
529 return [summary.turnId, result.diff] as const;515 ];
530 }),516 });
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 };
552 }, [517 }, [
553 activeSessionId,518 activeSessionId,
554 activeThreadId,519 activeThreadId,
555 api,520 api,
556 dispatch,
557 inferredCheckpointTurnCountByTurnId,521 inferredCheckpointTurnCountByTurnId,
558 turnDiffSummaries,522 turnDiffSummaries,
559 ]);523 ]);
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
561 const completionSummary = useMemo(() => {561 const completionSummary = useMemo(() => {
562 if (!activeThread?.latestTurnStartedAt) return null;562 if (!activeThread?.latestTurnStartedAt) return null;
563 if (!activeThread.latestTurnCompletedAt) return null;563 if (!activeThread.latestTurnCompletedAt) return null;
apps/web/src/components/DiffPanel.tsx+58-119
...@@ -1,7 +1,10 @@...@@ -1,7 +1,10 @@
1import { parsePatchFiles } from "@pierre/diffs";1import { parsePatchFiles } from "@pierre/diffs";
2import { FileDiff, type FileDiffMetadata } from "@pierre/diffs/react";2import { 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";
4import { useEffect, useMemo, useRef, useState } from "react";5import { useEffect, useMemo, useRef, useState } from "react";
6import { checkpointDiffQueryOptions, providerQueryKeys } from "~/lib/providerReactQuery";
7import { cn } from "~/lib/utils";
5import { isElectron } from "../env";8import { isElectron } from "../env";
6import { useNativeApi } from "../hooks/useNativeApi";9import { useNativeApi } from "../hooks/useNativeApi";
7import { useTheme } from "../hooks/useTheme";10import { useTheme } from "../hooks/useTheme";
...@@ -11,9 +14,6 @@ import {...@@ -11,9 +14,6 @@ import {
11 inferCheckpointTurnCountByTurnId,14 inferCheckpointTurnCountByTurnId,
12} from "../session-logic";15} from "../session-logic";
13import { useStore } from "../store";16import { useStore } from "../store";
14import { Button } from "./ui/button";
15import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
16import { cn } from "~/lib/utils";
17import { ToggleGroup, Toggle } from "./ui/toggle-group";17import { ToggleGroup, Toggle } from "./ui/toggle-group";
1818
19type DiffRenderMode = "stacked" | "split";19type DiffRenderMode = "stacked" | "split";
...@@ -63,14 +63,6 @@ function resolveFileDiffPath(fileDiff: FileDiffMetadata): string {...@@ -63,14 +63,6 @@ function resolveFileDiffPath(fileDiff: FileDiffMetadata): string {
63 return raw;63 return raw;
64}64}
6565
66function buildCheckpointDiffKey(
67 sessionId: string,
68 fromTurnCount: number,
69 toTurnCount: number,
70): string {
71 return `${sessionId}:${fromTurnCount}-${toTurnCount}`;
72}
73
74interface DiffPanelProps {66interface DiffPanelProps {
75 mode?: "inline" | "sheet";67 mode?: "inline" | "sheet";
76}68}
...@@ -79,12 +71,13 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -79,12 +71,13 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
79 const api = useNativeApi();71 const api = useNativeApi();
80 const { resolvedTheme } = useTheme();72 const { resolvedTheme } = useTheme();
81 const { state, dispatch } = useStore();73 const { state, dispatch } = useStore();
82 const [checkpointDiffByKey, setCheckpointDiffByKey] = useState<Record<string, string>>({});74 const queryClient = useQueryClient();
83 const [isLoadingCheckpointDiff, setIsLoadingCheckpointDiff] = useState(false);
84 const [checkpointDiffError, setCheckpointDiffError] = useState<string | null>(null);
85 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");75 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
86 const patchViewportRef = useRef<HTMLDivElement>(null);76 const patchViewportRef = useRef<HTMLDivElement>(null);
87 const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId);77 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;
88 const turnDiffSummaries = useMemo(81 const turnDiffSummaries = useMemo(
89 () =>82 () =>
90 activeThread?.turnDiffSummaries.length83 activeThread?.turnDiffSummaries.length
...@@ -97,11 +90,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -97,11 +90,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
97 [turnDiffSummaries],90 [turnDiffSummaries],
98 );91 );
9992
100 useEffect(() => {
101 setCheckpointDiffByKey({});
102 setCheckpointDiffError(null);
103 }, [activeThread?.id]);
104
105 const canApplyStoredTarget = Boolean(activeThread && state.diffThreadId === activeThread.id);93 const canApplyStoredTarget = Boolean(activeThread && state.diffThreadId === activeThread.id);
106 const selectedTurnId = canApplyStoredTarget ? state.diffTurnId : null;94 const selectedTurnId = canApplyStoredTarget ? state.diffTurnId : null;
107 const selectedFilePath =95 const selectedFilePath =
...@@ -147,41 +135,57 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -147,41 +135,57 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
147 : null,135 : null,
148 [conversationCheckpointTurnCount, selectedTurn],136 [conversationCheckpointTurnCount, selectedTurn],
149 );137 );
150 const selectedCheckpointDiffKey =138 const activeCheckpointRange = selectedTurn
151 activeThread?.session?.sessionId && selectedCheckpointRange139 ? selectedCheckpointRange
152 ? buildCheckpointDiffKey(140 : conversationCheckpointRange;
153 activeThread.session.sessionId,141 const activeCheckpointDiffQuery = useQuery(
154 selectedCheckpointRange.fromTurnCount,142 checkpointDiffQueryOptions(api, {
155 selectedCheckpointRange.toTurnCount,143 sessionId: activeSessionId,
156 )144 threadRuntimeId: activeThreadRuntimeId,
157 : null;145 fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,
158 const conversationCheckpointDiffKey =146 toTurnCount: activeCheckpointRange?.toTurnCount ?? null,
159 activeThread?.session?.sessionId && conversationCheckpointRange147 }),
160 ? buildCheckpointDiffKey(148 );
161 activeThread.session.sessionId,149 const selectedTurnCheckpointDiff = selectedTurn
162 conversationCheckpointRange.fromTurnCount,150 ? activeCheckpointDiffQuery.data?.diff
163 conversationCheckpointRange.toTurnCount,151 : undefined;
164 )152 const conversationCheckpointDiff = selectedTurn
165 : null;153 ? undefined
166 const selectedTurnCheckpointDiff =154 : activeCheckpointDiffQuery.data?.diff;
167 selectedCheckpointDiffKey === null ? undefined : checkpointDiffByKey[selectedCheckpointDiffKey];155 const isLoadingCheckpointDiff = activeCheckpointDiffQuery.isPending;
168 const conversationCheckpointDiff =156 const checkpointDiffError =
169 conversationCheckpointDiffKey === null157 activeCheckpointDiffQuery.error instanceof Error
170 ? undefined158 ? activeCheckpointDiffQuery.error.message
171 : checkpointDiffByKey[conversationCheckpointDiffKey];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
172 const selectedPatch = useMemo(() => {176 const selectedPatch = useMemo(() => {
173 const patchForSummary = (summary: (typeof turnDiffSummaries)[number]): string | undefined => {177 const patchForSummary = (summary: (typeof turnDiffSummaries)[number]): string | undefined => {
174 const checkpointTurnCount =178 const checkpointTurnCount =
175 summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId];179 summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId];
176 if (activeThread?.session?.sessionId && typeof checkpointTurnCount === "number") {180 if (activeSessionId && typeof checkpointTurnCount === "number") {
177 const checkpointPatch =181 const checkpointPatch = queryClient.getQueryData<{ diff: string }>(
178 checkpointDiffByKey[182 providerQueryKeys.checkpointDiff({
179 buildCheckpointDiffKey(183 sessionId: activeSessionId,
180 activeThread.session.sessionId,184 threadRuntimeId: activeThreadRuntimeId,
181 Math.max(0, checkpointTurnCount - 1),185 fromTurnCount: Math.max(0, checkpointTurnCount - 1),
182 checkpointTurnCount,186 toTurnCount: checkpointTurnCount,
183 )187 }),
184 ];188 )?.diff;
185 if (checkpointPatch) {189 if (checkpointPatch) {
186 return checkpointPatch;190 return checkpointPatch;
187 }191 }
...@@ -239,10 +243,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -239,10 +243,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
239 }243 }
240 return patches.join("\n\n");244 return patches.join("\n\n");
241 }, [245 }, [
242 activeThread?.session?.sessionId,246 activeSessionId,
243 checkpointDiffByKey,247 activeThreadRuntimeId,
244 conversationCheckpointDiff,248 conversationCheckpointDiff,
245 inferredCheckpointTurnCountByTurnId,249 inferredCheckpointTurnCountByTurnId,
250 queryClient,
246 selectedTurn,251 selectedTurn,
247 selectedTurnCheckpointDiff,252 selectedTurnCheckpointDiff,
248 turnDiffSummaries,253 turnDiffSummaries,
...@@ -260,72 +265,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -260,72 +265,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
260 );265 );
261 }, [renderablePatch]);266 }, [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
329 useEffect(() => {268 useEffect(() => {
330 if (!selectedFilePath || !patchViewportRef.current) {269 if (!selectedFilePath || !patchViewportRef.current) {
331 return;270 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", () => {...@@ -880,7 +880,12 @@ describe("store reducer thread continuity", () => {
880 {880 {
881 turnId: "turn-1",881 turnId: "turn-1",
882 completedAt: "2026-02-09T00:00:03.000Z",882 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 ],
884 unifiedDiff: "diff --git a/src/from-checkpoint.ts b/src/from-checkpoint.ts",889 unifiedDiff: "diff --git a/src/from-checkpoint.ts b/src/from-checkpoint.ts",
885 checkpointDiffLoaded: true,890 checkpointDiffLoaded: true,
886 },891 },
apps/web/src/store.ts+5-1
...@@ -1075,7 +1075,11 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -1075,7 +1075,11 @@ export function reducer(state: AppState, action: Action): AppState {
1075 existing.deletions === file.deletions1075 existing.deletions === file.deletions
1076 );1076 );
1077 });1077 });
1078 if (summary.unifiedDiff === checkpointDiff && filesUnchanged && summary.checkpointDiffLoaded) {1078 if (
1079 summary.unifiedDiff === checkpointDiff &&
1080 filesUnchanged &&
1081 summary.checkpointDiffLoaded
1082 ) {
1079 return summary;1083 return summary;
1080 }1084 }
10811085
packages/contracts/src/ipc.ts+3-1
...@@ -95,7 +95,9 @@ export interface NativeApi {...@@ -95,7 +95,9 @@ export interface NativeApi {
95 respondToRequest: (input: ProviderRespondToRequestInput) => Promise<void>;95 respondToRequest: (input: ProviderRespondToRequestInput) => Promise<void>;
96 stopSession: (input: ProviderStopSessionInput) => Promise<void>;96 stopSession: (input: ProviderStopSessionInput) => Promise<void>;
97 listSessions: () => Promise<ProviderSession[]>;97 listSessions: () => Promise<ProviderSession[]>;
98 listCheckpoints: (input: ProviderListCheckpointsInput) => Promise<ProviderListCheckpointsResult>;98 listCheckpoints: (
99 input: ProviderListCheckpointsInput,
100 ) => Promise<ProviderListCheckpointsResult>;
99 getCheckpointDiff: (101 getCheckpointDiff: (
100 input: ProviderGetCheckpointDiffInput,102 input: ProviderGetCheckpointDiffInput,
101 ) => Promise<ProviderGetCheckpointDiffResult>;103 ) => Promise<ProviderGetCheckpointDiffResult>;