From 3afbf93d5e54fec6674c62a5521f78c0ec2cf846 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 18 Feb 2026 01:06:33 -0800 Subject: [PATCH] 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 --- apps/server/src/filesystemCheckpointStore.ts | 38 +++- apps/server/src/providerManager.test.ts | 60 +++++-- apps/server/src/providerManager.ts | 4 +- apps/web/src/components/ChatView.tsx | 104 +++++------ apps/web/src/components/DiffPanel.tsx | 177 ++++++------------- apps/web/src/lib/providerReactQuery.ts | 52 ++++++ apps/web/src/store.test.ts | 7 +- apps/web/src/store.ts | 6 +- packages/contracts/src/ipc.ts | 4 +- 9 files changed, 258 insertions(+), 194 deletions(-) create mode 100644 apps/web/src/lib/providerReactQuery.ts diff --git a/apps/server/src/filesystemCheckpointStore.ts b/apps/server/src/filesystemCheckpointStore.ts index 516832bb545c94d8d46c4e29fbe92786f77f6914..41c58ed06a11c2d4e8d990205a80311d554c4967 100644 --- a/apps/server/src/filesystemCheckpointStore.ts +++ b/apps/server/src/filesystemCheckpointStore.ts @@ -38,7 +38,11 @@ export class FilesystemCheckpointStore { return result.code === 0 && result.stdout.trim() === "true"; } - async captureCheckpoint(input: { cwd: string; threadId: string; turnCount: number }): Promise { + async captureCheckpoint(input: { + cwd: string; + threadId: string; + turnCount: number; + }): Promise { const { cwd, threadId, turnCount } = input; const ref = checkpointRefForThreadTurn(threadId, turnCount); @@ -84,7 +88,11 @@ export class FilesystemCheckpointStore { } } - async hasCheckpoint(input: { cwd: string; threadId: string; turnCount: number }): Promise { + async hasCheckpoint(input: { + cwd: string; + threadId: string; + turnCount: number; + }): Promise { const { cwd, threadId, turnCount } = input; const ref = checkpointRefForThreadTurn(threadId, turnCount); const commit = await this.resolveCheckpointCommit(cwd, ref); @@ -108,7 +116,11 @@ export class FilesystemCheckpointStore { return true; } - async restoreCheckpoint(input: { cwd: string; threadId: string; turnCount: number }): Promise { + async restoreCheckpoint(input: { + cwd: string; + threadId: string; + turnCount: number; + }): Promise { const { cwd, threadId, turnCount } = input; const ref = checkpointRefForThreadTurn(threadId, turnCount); let commitOid = await this.resolveCheckpointCommit(cwd, ref); @@ -158,7 +170,9 @@ export class FilesystemCheckpointStore { ); } if (!toCommitOid) { - throw new Error(`Filesystem checkpoint is unavailable for turn ${toTurnCount} in thread ${threadId}.`); + throw new Error( + `Filesystem checkpoint is unavailable for turn ${toTurnCount} in thread ${threadId}.`, + ); } const result = await this.runGit(cwd, [ @@ -183,16 +197,24 @@ export class FilesystemCheckpointStore { return commit.length > 0 ? commit : null; } - async pruneAfterTurn(input: { cwd: string; threadId: string; maxTurnCount: number }): Promise { + async pruneAfterTurn(input: { + cwd: string; + threadId: string; + maxTurnCount: number; + }): Promise { const { cwd, threadId, maxTurnCount } = input; if (!Number.isInteger(maxTurnCount) || maxTurnCount < 0) { throw new Error(`Invalid max turn count: ${maxTurnCount}`); } const threadRefPrefix = `${CHECKPOINT_REFS_PREFIX}/${checkpointRefThreadSegment(threadId)}/turn/`; - const result = await this.runGit(cwd, ["for-each-ref", "--format=%(refname)", threadRefPrefix], { - allowNonZeroExit: true, - }); + const result = await this.runGit( + cwd, + ["for-each-ref", "--format=%(refname)", threadRefPrefix], + { + allowNonZeroExit: true, + }, + ); if (result.code !== 0) { return; } diff --git a/apps/server/src/providerManager.test.ts b/apps/server/src/providerManager.test.ts index 66baa5e53fdefd95aa2e11ec7c33261aec5528b4..5cac86588f3af8faa0bc1f786b565e0d1d2ae248 100644 --- a/apps/server/src/providerManager.test.ts +++ b/apps/server/src/providerManager.test.ts @@ -159,7 +159,10 @@ describe("ProviderManager", () => { threadId: string; turns: Array<{ id: string; items: unknown[] }>; }>; - rollbackThread: (sessionId: string, numTurns: number) => Promise<{ + rollbackThread: ( + sessionId: string, + numTurns: number, + ) => Promise<{ threadId: string; turns: Array<{ id: string; items: unknown[] }>; }>; @@ -253,7 +256,10 @@ describe("ProviderManager", () => { internals.codex.hasSession = () => true; internals.codex.readThread = async () => ({ threadId: "thr_1", - turns: [{ id: "turn_1", items: [] }, { id: "turn_2", items: [] }], + turns: [ + { id: "turn_1", items: [] }, + { id: "turn_2", items: [] }, + ], }); const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts"); internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints; @@ -330,7 +336,10 @@ describe("ProviderManager", () => { ]; internals.codex.readThread = async () => ({ threadId: "thr_1", - turns: [{ id: "turn_1", items: [] }, { id: "turn_2", items: [] }], + turns: [ + { id: "turn_1", items: [] }, + { id: "turn_2", items: [] }, + ], }); const isGitRepository = vi.fn(async () => true); const ensureRootCheckpoint = vi.fn(async () => true); @@ -471,19 +480,30 @@ describe("ProviderManager", () => { threadId: string; turns: Array<{ id: string; items: unknown[] }>; }>; - rollbackThread: (sessionId: string, numTurns: number) => Promise<{ + rollbackThread: ( + sessionId: string, + numTurns: number, + ) => Promise<{ threadId: string; turns: Array<{ id: string; items: unknown[] }>; }>; }; filesystemCheckpointStore: { - hasCheckpoint: (input: { cwd: string; threadId: string; turnCount: number }) => Promise; + hasCheckpoint: (input: { + cwd: string; + threadId: string; + turnCount: number; + }) => Promise; restoreCheckpoint: (input: { cwd: string; threadId: string; turnCount: number; }) => Promise; - pruneAfterTurn: (input: { cwd: string; threadId: string; maxTurnCount: number }) => Promise; + pruneAfterTurn: (input: { + cwd: string; + threadId: string; + maxTurnCount: number; + }) => Promise; }; sessionCheckpointCwds: Map; }; @@ -542,19 +562,30 @@ describe("ProviderManager", () => { threadId: string; turns: Array<{ id: string; items: unknown[] }>; }>; - rollbackThread: (sessionId: string, numTurns: number) => Promise<{ + rollbackThread: ( + sessionId: string, + numTurns: number, + ) => Promise<{ threadId: string; turns: Array<{ id: string; items: unknown[] }>; }>; }; filesystemCheckpointStore: { - hasCheckpoint: (input: { cwd: string; threadId: string; turnCount: number }) => Promise; + hasCheckpoint: (input: { + cwd: string; + threadId: string; + turnCount: number; + }) => Promise; restoreCheckpoint: (input: { cwd: string; threadId: string; turnCount: number; }) => Promise; - pruneAfterTurn: (input: { cwd: string; threadId: string; maxTurnCount: number }) => Promise; + pruneAfterTurn: (input: { + cwd: string; + threadId: string; + maxTurnCount: number; + }) => Promise; }; sessionCheckpointCwds: Map; }; @@ -609,13 +640,20 @@ describe("ProviderManager", () => { threadId: string; turns: Array<{ id: string; items: unknown[] }>; }>; - rollbackThread: (sessionId: string, numTurns: number) => Promise<{ + rollbackThread: ( + sessionId: string, + numTurns: number, + ) => Promise<{ threadId: string; turns: Array<{ id: string; items: unknown[] }>; }>; }; filesystemCheckpointStore: { - hasCheckpoint: (input: { cwd: string; threadId: string; turnCount: number }) => Promise; + hasCheckpoint: (input: { + cwd: string; + threadId: string; + turnCount: number; + }) => Promise; }; sessionCheckpointCwds: Map; }; diff --git a/apps/server/src/providerManager.ts b/apps/server/src/providerManager.ts index 5e55f0b1ce45188a081968dcb36efe3234689721..dd20804227d28a6b82d128674c4bdf179101b7fa 100644 --- a/apps/server/src/providerManager.ts +++ b/apps/server/src/providerManager.ts @@ -425,7 +425,9 @@ export class ProviderManager extends EventEmitter { return existingCwd; } - const session = this.codex.listSessions().find((candidate) => candidate.sessionId === sessionId); + const session = this.codex + .listSessions() + .find((candidate) => candidate.sessionId === sessionId); const candidateCwds = Array.from( new Set([session?.cwd, process.cwd()].filter((value): value is string => Boolean(value))), ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f4d0dcd0a438c1ea201c1ae15e7d830c0abfd8ac..86abebcc3124d4cb907e515faf92961d82700de1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -27,10 +27,11 @@ import { useRef, useState, } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; +import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; @@ -373,12 +374,11 @@ export default function ChatView() { const composerImagesRef = useRef([]); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); - const checkpointDiffRequestsRef = useRef(new Set()); const checkpointHydrationSessionRequestRef = useRef(new Set()); const activeThread = state.threads.find((t) => t.id === state.activeThreadId); const activeThreadId = activeThread?.id ?? null; - const activeSessionId = activeThread?.session?.sessionId; + const activeSessionId = activeThread?.session?.sessionId ?? null; const activeThreadRuntimeId = activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); @@ -433,6 +433,7 @@ export default function ChatView() { () => inferCheckpointTurnCountByTurnId(turnDiffSummaries), [turnDiffSummaries], ); + const revertTurnCountByUserMessageId = useMemo(() => { const byUserMessageId = new Map(); for (let index = 0; index < timelineEntries.length; index += 1) { @@ -496,68 +497,67 @@ export default function ChatView() { }); }, [activeThreadId, dispatch, inferredCheckpointTurnCountByTurnId, turnDiffSummaries]); - useEffect(() => { + const checkpointDiffHydrationTargets = useMemo(() => { if (!api || !activeThreadId || !activeSessionId || turnDiffSummaries.length === 0) { - return; + return []; } - - const turnSummariesNeedingDiff = turnDiffSummaries.filter( - (summary) => - !summary.checkpointDiffLoaded && inferredCheckpointTurnCountByTurnId[summary.turnId], - ); - const requestedSummaries = turnSummariesNeedingDiff.filter((summary) => { - const requestKey = `${activeThreadId}:${summary.turnId}`; - if (checkpointDiffRequestsRef.current.has(requestKey)) { - return false; + return turnDiffSummaries.flatMap((summary) => { + const turnCount = inferredCheckpointTurnCountByTurnId[summary.turnId]; + if (summary.checkpointDiffLoaded || typeof turnCount !== "number") { + return []; } - checkpointDiffRequestsRef.current.add(requestKey); - return true; - }); - if (requestedSummaries.length === 0) { - return; - } - - let cancelled = false; - void Promise.all( - requestedSummaries.map(async (summary) => { - const turnCount = inferredCheckpointTurnCountByTurnId[summary.turnId] ?? 0; - const result = await api.providers.getCheckpointDiff({ - sessionId: activeSessionId, + return [ + { + turnId: summary.turnId, fromTurnCount: Math.max(0, turnCount - 1), toTurnCount: turnCount, - }); - return [summary.turnId, result.diff] as const; - }), - ) - .then((entries) => { - if (cancelled) return; - dispatch({ - type: "SET_THREAD_TURN_CHECKPOINT_DIFFS", - threadId: activeThreadId, - checkpointDiffByTurnId: Object.fromEntries(entries), - }); - }) - .catch(() => { - if (cancelled) return; - }) - .finally(() => { - for (const summary of requestedSummaries) { - checkpointDiffRequestsRef.current.delete(`${activeThreadId}:${summary.turnId}`); - } - }); - - return () => { - cancelled = true; - }; + }, + ]; + }); }, [ activeSessionId, activeThreadId, api, - dispatch, inferredCheckpointTurnCountByTurnId, turnDiffSummaries, ]); + const checkpointDiffHydrationQueries = useQueries({ + queries: checkpointDiffHydrationTargets.map((target) => + checkpointDiffQueryOptions(api, { + sessionId: activeSessionId, + threadRuntimeId: activeThreadRuntimeId, + fromTurnCount: target.fromTurnCount, + toTurnCount: target.toTurnCount, + }), + ), + }); + + useEffect(() => { + if (!activeThreadId || checkpointDiffHydrationTargets.length === 0) { + return; + } + + const checkpointDiffByTurnId: Record = {}; + for (let index = 0; index < checkpointDiffHydrationTargets.length; index += 1) { + const target = checkpointDiffHydrationTargets[index]; + const query = checkpointDiffHydrationQueries[index]; + const diff = query?.data?.diff; + if (!target || !diff) { + continue; + } + checkpointDiffByTurnId[target.turnId] = diff; + } + if (Object.keys(checkpointDiffByTurnId).length === 0) { + return; + } + dispatch({ + type: "SET_THREAD_TURN_CHECKPOINT_DIFFS", + threadId: activeThreadId, + checkpointDiffByTurnId, + }); + }, [activeThreadId, checkpointDiffHydrationQueries, checkpointDiffHydrationTargets, dispatch]); + const completionSummary = useMemo(() => { if (!activeThread?.latestTurnStartedAt) return null; if (!activeThread.latestTurnCompletedAt) return null; diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 60e23d724f1acae6beaa7e0e42ccbbf7c25caa06..638c5dc7033cad5577765557b31578df7751ffff 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,7 +1,10 @@ import { parsePatchFiles } from "@pierre/diffs"; import { FileDiff, type FileDiffMetadata } from "@pierre/diffs/react"; -import { Columns2Icon, Rows3Icon, XIcon } from "lucide-react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Columns2Icon, Rows3Icon } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; +import { checkpointDiffQueryOptions, providerQueryKeys } from "~/lib/providerReactQuery"; +import { cn } from "~/lib/utils"; import { isElectron } from "../env"; import { useNativeApi } from "../hooks/useNativeApi"; import { useTheme } from "../hooks/useTheme"; @@ -11,9 +14,6 @@ import { inferCheckpointTurnCountByTurnId, } from "../session-logic"; import { useStore } from "../store"; -import { Button } from "./ui/button"; -import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; -import { cn } from "~/lib/utils"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; type DiffRenderMode = "stacked" | "split"; @@ -63,14 +63,6 @@ function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { return raw; } -function buildCheckpointDiffKey( - sessionId: string, - fromTurnCount: number, - toTurnCount: number, -): string { - return `${sessionId}:${fromTurnCount}-${toTurnCount}`; -} - interface DiffPanelProps { mode?: "inline" | "sheet"; } @@ -79,12 +71,13 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const api = useNativeApi(); const { resolvedTheme } = useTheme(); const { state, dispatch } = useStore(); - const [checkpointDiffByKey, setCheckpointDiffByKey] = useState>({}); - const [isLoadingCheckpointDiff, setIsLoadingCheckpointDiff] = useState(false); - const [checkpointDiffError, setCheckpointDiffError] = useState(null); + const queryClient = useQueryClient(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const patchViewportRef = useRef(null); const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId); + const activeThreadRuntimeId = + activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; + const activeSessionId = activeThread?.session?.sessionId ?? null; const turnDiffSummaries = useMemo( () => activeThread?.turnDiffSummaries.length @@ -97,11 +90,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { [turnDiffSummaries], ); - useEffect(() => { - setCheckpointDiffByKey({}); - setCheckpointDiffError(null); - }, [activeThread?.id]); - const canApplyStoredTarget = Boolean(activeThread && state.diffThreadId === activeThread.id); const selectedTurnId = canApplyStoredTarget ? state.diffTurnId : null; const selectedFilePath = @@ -147,41 +135,57 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { : null, [conversationCheckpointTurnCount, selectedTurn], ); - const selectedCheckpointDiffKey = - activeThread?.session?.sessionId && selectedCheckpointRange - ? buildCheckpointDiffKey( - activeThread.session.sessionId, - selectedCheckpointRange.fromTurnCount, - selectedCheckpointRange.toTurnCount, - ) - : null; - const conversationCheckpointDiffKey = - activeThread?.session?.sessionId && conversationCheckpointRange - ? buildCheckpointDiffKey( - activeThread.session.sessionId, - conversationCheckpointRange.fromTurnCount, - conversationCheckpointRange.toTurnCount, - ) - : null; - const selectedTurnCheckpointDiff = - selectedCheckpointDiffKey === null ? undefined : checkpointDiffByKey[selectedCheckpointDiffKey]; - const conversationCheckpointDiff = - conversationCheckpointDiffKey === null - ? undefined - : checkpointDiffByKey[conversationCheckpointDiffKey]; + const activeCheckpointRange = selectedTurn + ? selectedCheckpointRange + : conversationCheckpointRange; + const activeCheckpointDiffQuery = useQuery( + checkpointDiffQueryOptions(api, { + sessionId: activeSessionId, + threadRuntimeId: activeThreadRuntimeId, + fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, + toTurnCount: activeCheckpointRange?.toTurnCount ?? null, + }), + ); + const selectedTurnCheckpointDiff = selectedTurn + ? activeCheckpointDiffQuery.data?.diff + : undefined; + const conversationCheckpointDiff = selectedTurn + ? undefined + : activeCheckpointDiffQuery.data?.diff; + const isLoadingCheckpointDiff = activeCheckpointDiffQuery.isPending; + const checkpointDiffError = + activeCheckpointDiffQuery.error instanceof Error + ? activeCheckpointDiffQuery.error.message + : activeCheckpointDiffQuery.error + ? "Failed to load checkpoint diff." + : null; + + useEffect(() => { + if (!activeThread?.id || !selectedTurn || !selectedTurnCheckpointDiff) { + return; + } + dispatch({ + type: "SET_THREAD_TURN_CHECKPOINT_DIFFS", + threadId: activeThread.id, + checkpointDiffByTurnId: { + [selectedTurn.turnId]: selectedTurnCheckpointDiff, + }, + }); + }, [activeThread?.id, dispatch, selectedTurn, selectedTurnCheckpointDiff]); + const selectedPatch = useMemo(() => { const patchForSummary = (summary: (typeof turnDiffSummaries)[number]): string | undefined => { const checkpointTurnCount = summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; - if (activeThread?.session?.sessionId && typeof checkpointTurnCount === "number") { - const checkpointPatch = - checkpointDiffByKey[ - buildCheckpointDiffKey( - activeThread.session.sessionId, - Math.max(0, checkpointTurnCount - 1), - checkpointTurnCount, - ) - ]; + if (activeSessionId && typeof checkpointTurnCount === "number") { + const checkpointPatch = queryClient.getQueryData<{ diff: string }>( + providerQueryKeys.checkpointDiff({ + sessionId: activeSessionId, + threadRuntimeId: activeThreadRuntimeId, + fromTurnCount: Math.max(0, checkpointTurnCount - 1), + toTurnCount: checkpointTurnCount, + }), + )?.diff; if (checkpointPatch) { return checkpointPatch; } @@ -239,10 +243,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { } return patches.join("\n\n"); }, [ - activeThread?.session?.sessionId, - checkpointDiffByKey, + activeSessionId, + activeThreadRuntimeId, conversationCheckpointDiff, inferredCheckpointTurnCountByTurnId, + queryClient, selectedTurn, selectedTurnCheckpointDiff, turnDiffSummaries, @@ -260,72 +265,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { ); }, [renderablePatch]); - useEffect(() => { - const checkpointFetchRange = selectedTurn - ? selectedCheckpointRange - : conversationCheckpointRange; - const checkpointFetchKey = selectedTurn - ? selectedCheckpointDiffKey - : conversationCheckpointDiffKey; - if (!api || !activeThread?.session?.sessionId || !checkpointFetchRange || !checkpointFetchKey) { - return; - } - if (checkpointDiffByKey[checkpointFetchKey] !== undefined) { - return; - } - - let cancelled = false; - setIsLoadingCheckpointDiff(true); - setCheckpointDiffError(null); - void api.providers - .getCheckpointDiff({ - sessionId: activeThread.session.sessionId, - fromTurnCount: checkpointFetchRange.fromTurnCount, - toTurnCount: checkpointFetchRange.toTurnCount, - }) - .then((result) => { - if (cancelled) return; - setCheckpointDiffByKey((previous) => ({ - ...previous, - [checkpointFetchKey]: result.diff, - })); - if (selectedTurn) { - dispatch({ - type: "SET_THREAD_TURN_CHECKPOINT_DIFFS", - threadId: activeThread.id, - checkpointDiffByTurnId: { - [selectedTurn.turnId]: result.diff, - }, - }); - } - }) - .catch((error) => { - if (cancelled) return; - setCheckpointDiffError( - error instanceof Error ? error.message : "Failed to load checkpoint diff.", - ); - }) - .finally(() => { - if (cancelled) return; - setIsLoadingCheckpointDiff(false); - }); - - return () => { - cancelled = true; - }; - }, [ - activeThread?.id, - activeThread?.session?.sessionId, - api, - checkpointDiffByKey, - conversationCheckpointDiffKey, - conversationCheckpointRange, - dispatch, - selectedCheckpointDiffKey, - selectedCheckpointRange, - selectedTurn, - ]); - useEffect(() => { if (!selectedFilePath || !patchViewportRef.current) { return; diff --git a/apps/web/src/lib/providerReactQuery.ts b/apps/web/src/lib/providerReactQuery.ts new file mode 100644 index 0000000000000000000000000000000000000000..31f6bdf1e1fa445ba34cc4b481d0da1395c24e2e --- /dev/null +++ b/apps/web/src/lib/providerReactQuery.ts @@ -0,0 +1,52 @@ +import type { NativeApi } from "@t3tools/contracts"; +import { queryOptions } from "@tanstack/react-query"; + +export interface CheckpointDiffQueryInput { + sessionId: string | null; + threadRuntimeId: string | null; + fromTurnCount: number | null; + toTurnCount: number | null; +} + +export const providerQueryKeys = { + all: ["providers"] as const, + checkpointDiff: (input: CheckpointDiffQueryInput) => + [ + "providers", + "checkpointDiff", + input.sessionId, + input.threadRuntimeId, + input.fromTurnCount, + input.toTurnCount, + ] as const, +}; + +export function checkpointDiffQueryOptions( + api: NativeApi | undefined, + input: CheckpointDiffQueryInput, +) { + const hasValidRange = + typeof input.fromTurnCount === "number" && typeof input.toTurnCount === "number"; + + return queryOptions({ + queryKey: providerQueryKeys.checkpointDiff(input), + queryFn: async () => { + if (!api || !input.sessionId || !hasValidRange) { + throw new Error("Checkpoint diff is unavailable."); + } + const { fromTurnCount, toTurnCount } = input; + if (typeof fromTurnCount !== "number" || typeof toTurnCount !== "number") { + throw new Error("Checkpoint diff range is invalid."); + } + return api.providers.getCheckpointDiff({ + sessionId: input.sessionId, + fromTurnCount, + toTurnCount, + }); + }, + enabled: !!api && !!input.sessionId && hasValidRange, + staleTime: Infinity, + retry: 8, + retryDelay: (attempt) => Math.min(2_000, 150 * 2 ** (attempt - 1)), + }); +} diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 13b7d96ed482a4dd52150412bc74e28f81c7d72b..fbedc79a0997249cd2881ca5f26e158f3365e13f 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -880,7 +880,12 @@ describe("store reducer thread continuity", () => { { turnId: "turn-1", completedAt: "2026-02-09T00:00:03.000Z", - files: [{ path: "src/from-checkpoint.ts", diff: "diff --git a/src/from-checkpoint.ts b/src/from-checkpoint.ts" }], + files: [ + { + path: "src/from-checkpoint.ts", + diff: "diff --git a/src/from-checkpoint.ts b/src/from-checkpoint.ts", + }, + ], unifiedDiff: "diff --git a/src/from-checkpoint.ts b/src/from-checkpoint.ts", checkpointDiffLoaded: true, }, diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index f701f8101a176d68fa1d8c9daa18e01a7a2d3fca..f1b0fadb7ce9092d12f0bf5d492fdbfb555efa36 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1075,7 +1075,11 @@ export function reducer(state: AppState, action: Action): AppState { existing.deletions === file.deletions ); }); - if (summary.unifiedDiff === checkpointDiff && filesUnchanged && summary.checkpointDiffLoaded) { + if ( + summary.unifiedDiff === checkpointDiff && + filesUnchanged && + summary.checkpointDiffLoaded + ) { return summary; } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index c6b58563c57b49306e0c51fa85ccfa952a33cddb..ea5bc6ec4de8fce52354d76e51bbe65030e67fe3 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -95,7 +95,9 @@ export interface NativeApi { respondToRequest: (input: ProviderRespondToRequestInput) => Promise; stopSession: (input: ProviderStopSessionInput) => Promise; listSessions: () => Promise; - listCheckpoints: (input: ProviderListCheckpointsInput) => Promise; + listCheckpoints: ( + input: ProviderListCheckpointsInput, + ) => Promise; getCheckpointDiff: ( input: ProviderGetCheckpointDiffInput, ) => Promise; -- 2.54.0