authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-18 22:55:57-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-19 00:35:32-08:00
log9a8a5b3935fc9027de2af5b109a689038d6c14d1
tree0b3e9484cf7fac322b754fb919456b7ff536bd8a
parent58a8d653a8f16d3c0e8d8ff86728fcf4d28e32fb

Simplify turn diff summaries and harden checkpoint diff retries

- Remove persisted/stored patch bodies from turn diff summaries and keep metadata-only file stats - Drop checkpoint diff hydration paths in chat/diff views; rely on selected checkpoint/conversation diffs only - Sync checkpoint turn counts on session ensure and tune checkpoint diff query retry/backoff for not-ready errors - Update tests for session logic, persistence, store reducer, and provider query retry behavior

11 files changed, 197 insertions(+), 348 deletions(-)

apps/web/src/components/ChatView.tsx+85-133
......@@ -26,12 +26,11 @@ import {
2626 useRef,
2727 useState,
2828} from "react";
29import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
29import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3030import { useDebouncedValue } from "@tanstack/react-pacer";
3131import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual";
3232import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
3333import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
34import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
3534import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
3635
3736import { isElectron } from "../env";
......@@ -50,7 +49,6 @@ import {
5049 resolveModelSlug,
5150} from "../model-logic";
5251import {
53 countDiffStat,
5452 derivePendingApprovals,
5553 derivePhase,
5654 deriveTimelineEntries,
......@@ -380,6 +378,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
380378 const dragDepthRef = useRef(0);
381379 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
382380 const checkpointHydrationSessionRequestRef = useRef(new Set<string>());
381 const checkpointTurnCountSyncFingerprintRef = useRef(new Map<string, string>());
383382
384383 const activeThread = state.threads.find((t) => t.id === threadId);
385384 const activeThreadId = activeThread?.id ?? null;
......@@ -492,68 +491,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
492491 });
493492 }, [activeThreadId, dispatch, inferredCheckpointTurnCountByTurnId, turnDiffSummaries]);
494493
495 const checkpointDiffHydrationTargets = useMemo(() => {
496 if (!api || !activeThreadId || !activeSessionId || turnDiffSummaries.length === 0) {
497 return [];
498 }
499 return turnDiffSummaries.flatMap((summary) => {
500 const turnCount = inferredCheckpointTurnCountByTurnId[summary.turnId];
501 if (summary.checkpointDiffLoaded || typeof turnCount !== "number") {
502 return [];
503 }
504 return [
505 {
506 turnId: summary.turnId,
507 fromTurnCount: Math.max(0, turnCount - 1),
508 toTurnCount: turnCount,
509 },
510 ];
511 });
512 }, [
513 activeSessionId,
514 activeThreadId,
515 api,
516 inferredCheckpointTurnCountByTurnId,
517 turnDiffSummaries,
518 ]);
519
520 const checkpointDiffHydrationQueries = useQueries({
521 queries: checkpointDiffHydrationTargets.map((target) =>
522 checkpointDiffQueryOptions(api, {
523 sessionId: activeSessionId,
524 threadRuntimeId: activeThreadRuntimeId,
525 fromTurnCount: target.fromTurnCount,
526 toTurnCount: target.toTurnCount,
527 cacheScope: `turn:${target.turnId}`,
528 }),
529 ),
530 });
531
532 useEffect(() => {
533 if (!activeThreadId || checkpointDiffHydrationTargets.length === 0) {
534 return;
535 }
536
537 const checkpointDiffByTurnId: Record<string, string> = {};
538 for (let index = 0; index < checkpointDiffHydrationTargets.length; index += 1) {
539 const target = checkpointDiffHydrationTargets[index];
540 const query = checkpointDiffHydrationQueries[index];
541 const diff = query?.data?.diff;
542 if (!target || !diff) {
543 continue;
544 }
545 checkpointDiffByTurnId[target.turnId] = diff;
546 }
547 if (Object.keys(checkpointDiffByTurnId).length === 0) {
548 return;
549 }
550 dispatch({
551 type: "SET_THREAD_TURN_CHECKPOINT_DIFFS",
552 threadId: activeThreadId,
553 checkpointDiffByTurnId,
554 });
555 }, [activeThreadId, checkpointDiffHydrationQueries, checkpointDiffHydrationTargets, dispatch]);
556
557494 const completionSummary = useMemo(() => {
558495 if (!activeThread?.latestTurnStartedAt) return null;
559496 if (!activeThread.latestTurnCompletedAt) return null;
......@@ -1407,6 +1344,53 @@ export default function ChatView({ threadId }: ChatViewProps) {
14071344 focusComposer();
14081345 };
14091346
1347 const syncCheckpointTurnCounts = useCallback(
1348 async (input: {
1349 threadId: string;
1350 sessionId: string;
1351 threadRuntimeId: string | null;
1352 }): Promise<void> => {
1353 if (!api || turnDiffSummaries.length === 0) {
1354 return;
1355 }
1356
1357 const turnIds = turnDiffSummaries.map((summary) => summary.turnId);
1358 if (turnIds.length === 0) {
1359 return;
1360 }
1361
1362 const syncKey = `${input.threadId}:${input.sessionId}:${input.threadRuntimeId ?? "none"}`;
1363 const turnFingerprint = turnIds.join(",");
1364 if (checkpointTurnCountSyncFingerprintRef.current.get(syncKey) === turnFingerprint) {
1365 return;
1366 }
1367 checkpointTurnCountSyncFingerprintRef.current.set(syncKey, turnFingerprint);
1368
1369 try {
1370 const result = await api.providers.listCheckpoints({
1371 sessionId: input.sessionId,
1372 });
1373 const turnIdSet = new Set(turnIds);
1374 const checkpointTurnCountByTurnId = Object.fromEntries(
1375 result.checkpoints
1376 .filter((checkpoint) => checkpoint.turnCount > 0 && turnIdSet.has(checkpoint.id))
1377 .map((checkpoint) => [checkpoint.id, checkpoint.turnCount] as const),
1378 );
1379 if (Object.keys(checkpointTurnCountByTurnId).length === 0) {
1380 return;
1381 }
1382 dispatch({
1383 type: "SET_THREAD_TURN_CHECKPOINT_COUNTS",
1384 threadId: input.threadId,
1385 checkpointTurnCountByTurnId,
1386 });
1387 } catch {
1388 checkpointTurnCountSyncFingerprintRef.current.delete(syncKey);
1389 }
1390 },
1391 [api, dispatch, turnDiffSummaries],
1392 );
1393
14101394 const ensureSession = useCallback(
14111395 async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => {
14121396 if (!api || !activeThread || !activeProject) return null;
......@@ -1418,6 +1402,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
14181402 : sessionThreadId === activeThread.codexThreadId
14191403 ? "resumed"
14201404 : "fallback_new";
1405 await syncCheckpointTurnCounts({
1406 threadId: activeThread.id,
1407 sessionId: activeThread.session.sessionId,
1408 threadRuntimeId: sessionThreadId,
1409 });
14211410 return {
14221411 sessionId: activeThread.session.sessionId,
14231412 resolvedThreadId: sessionThreadId,
......@@ -1448,6 +1437,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
14481437 : resolvedThreadId === priorCodexThreadId
14491438 ? "resumed"
14501439 : "fallback_new";
1440 await syncCheckpointTurnCounts({
1441 threadId: activeThread.id,
1442 sessionId: session.sessionId,
1443 threadRuntimeId: resolvedThreadId,
1444 });
14511445 return {
14521446 sessionId: session.sessionId,
14531447 resolvedThreadId,
......@@ -1472,6 +1466,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
14721466 runtimeApprovalPolicy,
14731467 runtimeSandboxMode,
14741468 selectedModel,
1469 syncCheckpointTurnCounts,
14751470 ],
14761471 );
14771472
......@@ -1480,22 +1475,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
14801475 if (phase === "running" || isSending || isConnecting || isRevertingCheckpoint) return;
14811476 const hasThreadIdentity = Boolean(activeThreadRuntimeId);
14821477 if (!hasThreadIdentity) return;
1483 const selectedDiffTurnId =
1484 state.diffOpen && state.diffThreadId === activeThreadId ? state.diffTurnId : null;
1485 const selectedDiffTurn =
1486 selectedDiffTurnId === null
1487 ? undefined
1488 : (turnDiffSummaries.find((summary) => summary.turnId === selectedDiffTurnId) ??
1489 turnDiffSummaries[0]);
1490 const selectedTurnMissingPatchBody = Boolean(
1491 selectedDiffTurn &&
1492 !selectedDiffTurn.unifiedDiff &&
1493 selectedDiffTurn.files.every((file) => !file.diff),
1494 );
1495 const hasPendingDiffHydration =
1496 turnDiffSummaries.some((summary) => !summary.checkpointDiffLoaded) ||
1497 selectedTurnMissingPatchBody;
1498 if (!hasPendingDiffHydration) return;
1478 if (turnDiffSummaries.length === 0) return;
14991479
15001480 const requestKey = `${activeThreadId}:${activeThreadRuntimeId ?? "none"}`;
15011481 if (checkpointHydrationSessionRequestRef.current.has(requestKey)) {
......@@ -1522,9 +1502,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
15221502 isSending,
15231503 phase,
15241504 setThreadError,
1525 state.diffOpen,
1526 state.diffThreadId,
1527 state.diffTurnId,
15281505 turnDiffSummaries,
15291506 ]);
15301507
......@@ -2739,50 +2716,32 @@ const MessagesTimeline = memo(function MessagesTimeline({
27392716 row.message.id,
27402717 );
27412718 if (!turnSummary) return null;
2742 const isCheckpointDiffLoading =
2743 !turnSummary.checkpointDiffLoaded && turnSummary.files.length === 0;
2744 const summaryStat = turnSummary.unifiedDiff
2745 ? countDiffStat(turnSummary.unifiedDiff)
2746 : turnSummary.files.reduce(
2747 (acc, file) => {
2748 const next =
2749 typeof file.additions === "number" &&
2750 typeof file.deletions === "number"
2751 ? { additions: file.additions, deletions: file.deletions }
2752 : file.diff
2753 ? countDiffStat(file.diff)
2754 : null;
2755 if (!next) {
2756 return acc;
2757 }
2758 return {
2759 additions: acc.additions + next.additions,
2760 deletions: acc.deletions + next.deletions,
2761 };
2762 },
2763 { additions: 0, deletions: 0 },
2764 );
2765 const changedFileCountLabel = isCheckpointDiffLoading
2766 ? "..."
2767 : String(turnSummary.files.length);
2719 const summaryStat = turnSummary.files.reduce(
2720 (acc, file) => {
2721 if (typeof file.additions !== "number" || typeof file.deletions !== "number") {
2722 return acc;
2723 }
2724 return {
2725 additions: acc.additions + file.additions,
2726 deletions: acc.deletions + file.deletions,
2727 };
2728 },
2729 { additions: 0, deletions: 0 },
2730 );
2731 const changedFileCountLabel = String(turnSummary.files.length);
27682732 return (
27692733 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
27702734 <div className="mb-1.5 flex items-center justify-between gap-2">
27712735 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
27722736 <span>Changed files ({changedFileCountLabel})</span>
2773 {!isCheckpointDiffLoading &&
2774 (summaryStat.additions > 0 || summaryStat.deletions > 0) && (
2775 <>
2776 <span className="mx-1">•</span>
2777 <span className="text-success">
2778 +{summaryStat.additions}
2779 </span>
2780 <span className="mx-0.5 text-muted-foreground/70">/</span>
2781 <span className="text-destructive">
2782 -{summaryStat.deletions}
2783 </span>
2784 </>
2785 )}
2737 {(summaryStat.additions > 0 || summaryStat.deletions > 0) && (
2738 <>
2739 <span className="mx-1">•</span>
2740 <span className="text-success">+{summaryStat.additions}</span>
2741 <span className="mx-0.5 text-muted-foreground/70">/</span>
2742 <span className="text-destructive">-{summaryStat.deletions}</span>
2743 </>
2744 )}
27862745 </p>
27872746 <Button
27882747 type="button"
......@@ -2795,11 +2754,6 @@ const MessagesTimeline = memo(function MessagesTimeline({
27952754 View diff
27962755 </Button>
27972756 </div>
2798 {isCheckpointDiffLoading && (
2799 <p className="mb-1.5 text-[11px] text-muted-foreground/70">
2800 Loading checkpoint diff...
2801 </p>
2802 )}
28032757 {turnSummary.files.length > 0 ? (
28042758 <div className="flex flex-wrap gap-1.5">
28052759 {turnSummary.files.map((file) => (
......@@ -2817,9 +2771,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
28172771 additions: file.additions,
28182772 deletions: file.deletions,
28192773 }
2820 : file.diff
2821 ? countDiffStat(file.diff)
2822 : null;
2774 : null;
28232775 if (!stat) {
28242776 return file.path;
28252777 }
......@@ -2841,11 +2793,11 @@ const MessagesTimeline = memo(function MessagesTimeline({
28412793 </button>
28422794 ))}
28432795 </div>
2844 ) : !isCheckpointDiffLoading ? (
2796 ) : (
28452797 <p className="text-[11px] text-muted-foreground/70">
28462798 No changed files.
28472799 </p>
2848 ) : null}
2800 )}
28492801 </div>
28502802 );
28512803 })()}
apps/web/src/components/DiffPanel.tsx+3-96
......@@ -5,11 +5,11 @@ import {
55 Virtualizer,
66 WorkerPoolContextProvider,
77} from "@pierre/diffs/react";
8import { useQuery, useQueryClient } from "@tanstack/react-query";
8import { useQuery } from "@tanstack/react-query";
99import { useParams } from "@tanstack/react-router";
1010import { Columns2Icon, Rows3Icon } from "lucide-react";
1111import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
12import { checkpointDiffQueryOptions, providerQueryKeys } from "~/lib/providerReactQuery";
12import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
1313import { cn } from "~/lib/utils";
1414import { isElectron } from "../env";
1515import { useNativeApi } from "../hooks/useNativeApi";
......@@ -103,7 +103,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
103103 const api = useNativeApi();
104104 const { resolvedTheme } = useTheme();
105105 const { state, dispatch } = useStore();
106 const queryClient = useQueryClient();
107106 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
108107 const patchViewportRef = useRef<HTMLDivElement>(null);
109108 const params = useParams({ strict: false });
......@@ -193,99 +192,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
193192 ? "Failed to load checkpoint diff."
194193 : null;
195194
196 useEffect(() => {
197 if (!activeThread?.id || !selectedTurn || !selectedTurnCheckpointDiff) {
198 return;
199 }
200 dispatch({
201 type: "SET_THREAD_TURN_CHECKPOINT_DIFFS",
202 threadId: activeThread.id,
203 checkpointDiffByTurnId: {
204 [selectedTurn.turnId]: selectedTurnCheckpointDiff,
205 },
206 });
207 }, [activeThread?.id, dispatch, selectedTurn, selectedTurnCheckpointDiff]);
208
209 const selectedPatch = useMemo(() => {
210 const patchForSummary = (summary: (typeof turnDiffSummaries)[number]): string | undefined => {
211 const checkpointTurnCount =
212 summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId];
213 if (activeSessionId && typeof checkpointTurnCount === "number") {
214 const checkpointPatch = queryClient.getQueryData<{ diff: string }>(
215 providerQueryKeys.checkpointDiff({
216 sessionId: activeSessionId,
217 threadRuntimeId: activeThreadRuntimeId,
218 fromTurnCount: Math.max(0, checkpointTurnCount - 1),
219 toTurnCount: checkpointTurnCount,
220 cacheScope: `turn:${summary.turnId}`,
221 }),
222 )?.diff;
223 if (checkpointPatch) {
224 return checkpointPatch;
225 }
226 }
227 if (summary.unifiedDiff) {
228 return summary.unifiedDiff;
229 }
230 const filePatches = summary.files
231 .map((file) => file.diff?.trim())
232 .filter((patch): patch is string => Boolean(patch));
233 if (filePatches.length === 0) {
234 return undefined;
235 }
236 return filePatches.join("\n\n");
237 };
238
239 if (selectedTurn) {
240 return selectedTurnCheckpointDiff ?? patchForSummary(selectedTurn);
241 }
242
243 if (conversationCheckpointDiff) {
244 return conversationCheckpointDiff;
245 }
246
247 // Fallback when a conversation checkpoint diff isn't available yet:
248 // keep one patch per file path (latest change wins) so files aren't duplicated.
249 const latestPatchByPath = new Map<string, string>();
250 for (const summary of turnDiffSummaries) {
251 for (const file of summary.files) {
252 if (latestPatchByPath.has(file.path)) {
253 continue;
254 }
255 const patch = file.diff?.trim();
256 if (!patch) {
257 continue;
258 }
259 latestPatchByPath.set(file.path, patch);
260 }
261 }
262 if (latestPatchByPath.size > 0) {
263 return Array.from(latestPatchByPath.entries())
264 .toSorted(([leftPath], [rightPath]) =>
265 leftPath.localeCompare(rightPath, undefined, { numeric: true, sensitivity: "base" }),
266 )
267 .map(([, patch]) => patch)
268 .join("\n\n");
269 }
270
271 const patches = turnDiffSummaries
272 .toReversed()
273 .map((summary) => patchForSummary(summary)?.trim())
274 .filter((patch): patch is string => Boolean(patch));
275 if (patches.length === 0) {
276 return undefined;
277 }
278 return patches.join("\n\n");
279 }, [
280 activeSessionId,
281 activeThreadRuntimeId,
282 conversationCheckpointDiff,
283 inferredCheckpointTurnCountByTurnId,
284 queryClient,
285 selectedTurn,
286 selectedTurnCheckpointDiff,
287 turnDiffSummaries,
288 ]);
195 const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff;
289196 const renderablePatch = useMemo(() => getRenderablePatch(selectedPatch), [selectedPatch]);
290197 const renderableFiles = useMemo(() => {
291198 if (!renderablePatch || renderablePatch.kind !== "files") {
apps/web/src/lib/providerReactQuery.test.ts+50
......@@ -52,4 +52,54 @@ describe("checkpointDiffQueryOptions", () => {
5252 toTurnCount: 4,
5353 });
5454 });
55
56 it("retries checkpoint-not-ready errors longer than generic failures", () => {
57 const options = checkpointDiffQueryOptions(undefined, {
58 sessionId: "session-id",
59 threadRuntimeId: "thread-id",
60 fromTurnCount: 1,
61 toTurnCount: 2,
62 cacheScope: "turn:abc",
63 });
64 const retry = options.retry;
65
66 expect(retry?.(1, new Error("Checkpoint turn count 2 exceeds current turn count 1."))).toBe(
67 true,
68 );
69 expect(
70 retry?.(
71 11,
72 new Error("Filesystem checkpoint is unavailable for turn 2 in thread thread-1."),
73 ),
74 ).toBe(true);
75 expect(
76 retry?.(
77 12,
78 new Error("Filesystem checkpoint is unavailable for turn 2 in thread thread-1."),
79 ),
80 ).toBe(false);
81 expect(retry?.(2, new Error("Something else failed."))).toBe(true);
82 expect(retry?.(3, new Error("Something else failed."))).toBe(false);
83 });
84
85 it("backs off longer for checkpoint-not-ready errors", () => {
86 const options = checkpointDiffQueryOptions(undefined, {
87 sessionId: "session-id",
88 threadRuntimeId: "thread-id",
89 fromTurnCount: 1,
90 toTurnCount: 2,
91 cacheScope: "turn:abc",
92 });
93 const retryDelay = options.retryDelay;
94
95 const checkpointDelay = retryDelay?.(
96 4,
97 new Error("Checkpoint turn count 2 exceeds current turn count 1."),
98 );
99 const genericDelay = retryDelay?.(4, new Error("Network failure"));
100
101 expect(typeof checkpointDelay).toBe("number");
102 expect(typeof genericDelay).toBe("number");
103 expect((checkpointDelay ?? 0) > (genericDelay ?? 0)).toBe(true);
104 });
55105});
apps/web/src/lib/providerReactQuery.ts+29-2
......@@ -23,6 +23,25 @@ export const providerQueryKeys = {
2323 ] as const,
2424};
2525
26function asCheckpointErrorMessage(error: unknown): string {
27 if (error instanceof Error) {
28 return error.message;
29 }
30 if (typeof error === "string") {
31 return error;
32 }
33 return "";
34}
35
36function isCheckpointTemporarilyUnavailable(error: unknown): boolean {
37 const message = asCheckpointErrorMessage(error).toLowerCase();
38 return (
39 message.includes("exceeds current turn count") ||
40 message.includes("checkpoint is unavailable for turn") ||
41 message.includes("filesystem checkpoint is unavailable")
42 );
43}
44
2645export function checkpointDiffQueryOptions(
2746 api: NativeApi | undefined,
2847 input: CheckpointDiffQueryInput,
......@@ -54,7 +73,15 @@ export function checkpointDiffQueryOptions(
5473 },
5574 enabled: !!api && !!input.sessionId && hasValidRange,
5675 staleTime: Infinity,
57 retry: 8,
58 retryDelay: (attempt) => Math.min(2_000, 150 * 2 ** (attempt - 1)),
76 retry: (failureCount, error) => {
77 if (isCheckpointTemporarilyUnavailable(error)) {
78 return failureCount < 12;
79 }
80 return failureCount < 3;
81 },
82 retryDelay: (attempt, error) =>
83 isCheckpointTemporarilyUnavailable(error)
84 ? Math.min(5_000, 250 * 2 ** (attempt - 1))
85 : Math.min(1_000, 100 * 2 ** (attempt - 1)),
5986 });
6087}
apps/web/src/persistenceSchema.test.ts+1-4
......@@ -263,7 +263,7 @@ describe("hydratePersistedState", () => {
263263 expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`);
264264 });
265265
266 it("hydrates persisted turn diff summaries and clears persisted loaded flags", () => {
266 it("hydrates persisted turn diff summaries as metadata-only entries", () => {
267267 const payload = JSON.stringify({
268268 version: 7,
269269 runtimeMode: "full-access",
......@@ -290,7 +290,6 @@ describe("hydratePersistedState", () => {
290290 turnId: "turn-1",
291291 completedAt: "2026-02-08T10:05:00.000Z",
292292 checkpointTurnCount: 1,
293 checkpointDiffLoaded: true,
294293 files: [
295294 {
296295 path: "src/app.ts",
......@@ -421,10 +420,8 @@ describe("toPersistedState", () => {
421420 kind: "modified",
422421 additions: 3,
423422 deletions: 1,
424 diff: "diff --git a/src/app.ts b/src/app.ts",
425423 },
426424 ],
427 unifiedDiff: "diff --git a/src/app.ts b/src/app.ts",
428425 },
429426 ],
430427 };
apps/web/src/persistenceSchema.ts-1
......@@ -61,7 +61,6 @@ const persistedTurnDiffSummarySchema = z.object({
6161 files: z.array(persistedTurnDiffFileChangeSchema),
6262 assistantMessageId: z.string().min(1).optional(),
6363 checkpointTurnCount: z.number().int().min(0).optional(),
64 checkpointDiffLoaded: z.boolean().optional(),
6564});
6665
6766const persistedThreadSchema = z.object({
apps/web/src/session-logic.test.ts+6-6
......@@ -493,11 +493,11 @@ describe("deriveTurnDiffSummaries", () => {
493493 expect(summaries).toHaveLength(1);
494494 expect(summaries[0]?.turnId).toBe("turn-1");
495495 expect(summaries[0]?.assistantMessageId).toBe("msg-1");
496 expect(summaries[0]?.unifiedDiff).toContain("diff --git a/src/a.ts b/src/a.ts");
497496 expect(summaries[0]?.files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]);
498497 expect(summaries[0]?.files[0]?.kind).toBe("modified");
499498 expect(summaries[0]?.files[0]?.additions).toBe(1);
500499 expect(summaries[0]?.files[0]?.deletions).toBe(1);
500 expect("unifiedDiff" in (summaries[0] as Record<string, unknown>)).toBe(false);
501501 });
502502
503503 it("includes completed turns even when no fileChange tool events were emitted", () => {
......@@ -537,9 +537,10 @@ describe("deriveTurnDiffSummaries", () => {
537537 payload: {
538538 diff: [
539539 "diff --git a/src/example.ts b/src/example.ts",
540 "@@ -1 +1 @@",
540 "@@ -1 +1,2 @@",
541541 "-old",
542542 "+older",
543 "+extra",
543544 ].join("\n"),
544545 },
545546 }),
......@@ -572,8 +573,9 @@ describe("deriveTurnDiffSummaries", () => {
572573 ]);
573574
574575 expect(summaries).toHaveLength(1);
575 expect(summaries[0]?.unifiedDiff).toContain("+newest");
576 expect(summaries[0]?.unifiedDiff).not.toContain("+older");
576 expect(summaries[0]?.files[0]?.path).toBe("src/example.ts");
577 expect(summaries[0]?.files[0]?.additions).toBe(1);
578 expect(summaries[0]?.files[0]?.deletions).toBe(1);
577579 });
578580});
579581
......@@ -593,8 +595,6 @@ describe("deriveTurnDiffFilesFromUnifiedDiff", () => {
593595 );
594596
595597 expect(files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]);
596 expect(files[0]?.diff).toContain("diff --git a/src/a.ts b/src/a.ts");
597 expect(files[1]?.diff).toContain("diff --git a/src/b.ts b/src/b.ts");
598598 expect(files[0]?.additions).toBe(1);
599599 expect(files[0]?.deletions).toBe(1);
600600 });
apps/web/src/session-logic.ts+4-7
......@@ -453,7 +453,7 @@ interface MutableTurnDiffSummary {
453453 completedAt: string | undefined;
454454 status: string | undefined;
455455 assistantMessageId: string | undefined;
456 unifiedDiff: string | undefined;
456 hasTurnDiffUpdate: boolean;
457457 filesByPath: Map<string, TurnDiffFileChange>;
458458}
459459
......@@ -485,7 +485,6 @@ function parseFileChangeEntriesFromEvent(event: ProviderEvent): TurnDiffFileChan
485485 parsed.push({
486486 path,
487487 ...(kind ? { kind } : {}),
488 ...(diff ? { diff } : {}),
489488 ...(additions !== undefined ? { additions } : stat ? { additions: stat.additions } : {}),
490489 ...(deletions !== undefined ? { deletions } : stat ? { deletions: stat.deletions } : {}),
491490 });
......@@ -565,7 +564,6 @@ export function deriveTurnDiffFilesFromUnifiedDiff(diff: string): TurnDiffFileCh
565564 const stat = countDiffStat(fileDiff);
566565 return {
567566 path,
568 diff: fileDiff,
569567 additions: stat.additions,
570568 deletions: stat.deletions,
571569 };
......@@ -645,7 +643,7 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar
645643 completedAt: undefined,
646644 status: undefined,
647645 assistantMessageId: undefined,
648 unifiedDiff: undefined,
646 hasTurnDiffUpdate: false,
649647 filesByPath: new Map<string, TurnDiffFileChange>(),
650648 };
651649 byTurnId.set(turnId, next);
......@@ -677,10 +675,10 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar
677675 }
678676 }
679677
680 if (event.method === "turn/diff/updated" && summary.unifiedDiff === undefined) {
678 if (event.method === "turn/diff/updated" && !summary.hasTurnDiffUpdate) {
679 summary.hasTurnDiffUpdate = true;
681680 const diff = normalizeDetail(asString(asObject(event.payload)?.diff));
682681 if (diff) {
683 summary.unifiedDiff = diff;
684682 for (const file of deriveTurnDiffFilesFromUnifiedDiff(diff)) {
685683 mergeTurnDiffFileChange(summary.filesByPath, file);
686684 }
......@@ -703,7 +701,6 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar
703701 files: Array.from(summary.filesByPath.values()).toSorted((a, b) =>
704702 a.path.localeCompare(b.path),
705703 ),
706 ...(summary.unifiedDiff ? { unifiedDiff: summary.unifiedDiff } : {}),
707704 ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}),
708705 });
709706 }
apps/web/src/store.test.ts+18-24
......@@ -911,7 +911,7 @@ describe("store reducer thread continuity", () => {
911911 expect(next.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn_1"]);
912912 });
913913
914 it("keeps checkpoint-derived turn files when later events for the same turn arrive", () => {
914 it("keeps existing turn file metadata when later events for the same turn arrive", () => {
915915 const state = makeState(
916916 makeThread({
917917 turnDiffSummaries: [
......@@ -921,11 +921,10 @@ describe("store reducer thread continuity", () => {
921921 files: [
922922 {
923923 path: "src/from-checkpoint.ts",
924 diff: "diff --git a/src/from-checkpoint.ts b/src/from-checkpoint.ts",
924 additions: 1,
925 deletions: 1,
925926 },
926927 ],
927 unifiedDiff: "diff --git a/src/from-checkpoint.ts b/src/from-checkpoint.ts",
928 checkpointDiffLoaded: true,
929928 },
930929 ],
931930 }),
......@@ -945,11 +944,11 @@ describe("store reducer thread continuity", () => {
945944 expect(next.threads[0]?.turnDiffSummaries[0]?.files.map((file) => file.path)).toEqual([
946945 "src/from-checkpoint.ts",
947946 ]);
948 expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointDiffLoaded).toBe(true);
949 expect(next.threads[0]?.turnDiffSummaries[0]?.unifiedDiff).toContain("from-checkpoint.ts");
947 expect(next.threads[0]?.turnDiffSummaries[0]?.files[0]?.additions).toBe(1);
948 expect(next.threads[0]?.turnDiffSummaries[0]?.files[0]?.deletions).toBe(1);
950949 });
951950
952 it("updates turn summaries from checkpoint diffs and marks them as loaded", () => {
951 it("updates checkpoint turn counts from authoritative checkpoint mappings", () => {
953952 const state = makeState(
954953 makeThread({
955954 turnDiffSummaries: [
......@@ -959,31 +958,26 @@ describe("store reducer thread continuity", () => {
959958 files: [],
960959 checkpointTurnCount: 1,
961960 },
961 {
962 turnId: "turn-2",
963 completedAt: "2026-02-09T00:00:04.000Z",
964 files: [],
965 checkpointTurnCount: 2,
966 },
962967 ],
963968 }),
964969 );
965970
966971 const next = reducer(state, {
967 type: "SET_THREAD_TURN_CHECKPOINT_DIFFS",
972 type: "SET_THREAD_TURN_CHECKPOINT_COUNTS",
968973 threadId: "thread-local-1",
969 checkpointDiffByTurnId: {
970 "turn-1": [
971 "diff --git a/src/a.ts b/src/a.ts",
972 "@@ -1 +1 @@",
973 "-old-a",
974 "+new-a",
975 "diff --git a/src/b.ts b/src/b.ts",
976 "@@ -1 +1 @@",
977 "-old-b",
978 "+new-b",
979 ].join("\n"),
974 checkpointTurnCountByTurnId: {
975 "turn-1": 2,
976 "turn-2": 3,
980977 },
981978 });
982979
983 expect(next.threads[0]?.turnDiffSummaries[0]?.files.map((file) => file.path)).toEqual([
984 "src/a.ts",
985 "src/b.ts",
986 ]);
987 expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointDiffLoaded).toBe(true);
980 expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointTurnCount).toBe(2);
981 expect(next.threads[0]?.turnDiffSummaries[1]?.checkpointTurnCount).toBe(3);
988982 });
989983});
apps/web/src/store.ts+1-72
......@@ -15,7 +15,6 @@ import {
1515 applyEventToMessages,
1616 asObject,
1717 asString,
18 deriveTurnDiffFilesFromUnifiedDiff,
1918 deriveTurnDiffSummaries,
2019 inferCheckpointTurnCountByTurnId,
2120 evolveSession,
......@@ -94,11 +93,6 @@ type Action =
9493 threadId: string;
9594 checkpointTurnCountByTurnId: Record<string, number>;
9695 }
97 | {
98 type: "SET_THREAD_TURN_CHECKPOINT_DIFFS";
99 threadId: string;
100 checkpointDiffByTurnId: Record<string, string>;
101 }
10296 | {
10397 type: "SET_THREAD_BRANCH";
10498 threadId: string;
......@@ -236,21 +230,12 @@ function mergeTurnDiffSummaries(
236230 return summary;
237231 }
238232
239 const prefersCheckpointDiff = previous.checkpointDiffLoaded || summary.checkpointDiffLoaded;
240233 const files =
241 prefersCheckpointDiff ||
242 (summary.files.length === 0 && previous.files.length > 0 && summary.unifiedDiff === undefined)
243 ? previous.files
244 : summary.files;
245 const unifiedDiff =
246 prefersCheckpointDiff || summary.unifiedDiff === undefined
247 ? (previous.unifiedDiff ?? summary.unifiedDiff)
248 : summary.unifiedDiff;
234 summary.files.length === 0 && previous.files.length > 0 ? previous.files : summary.files;
249235
250236 return {
251237 ...summary,
252238 files,
253 ...(unifiedDiff !== undefined ? { unifiedDiff } : {}),
254239 ...(summary.assistantMessageId
255240 ? {}
256241 : previous.assistantMessageId
......@@ -261,9 +246,6 @@ function mergeTurnDiffSummaries(
261246 : typeof previous.checkpointTurnCount === "number"
262247 ? { checkpointTurnCount: previous.checkpointTurnCount }
263248 : {}),
264 ...(summary.checkpointDiffLoaded || previous.checkpointDiffLoaded
265 ? { checkpointDiffLoaded: true }
266 : {}),
267249 };
268250 });
269251
......@@ -1071,59 +1053,6 @@ export function reducer(state: AppState, action: Action): AppState {
10711053 }),
10721054 };
10731055
1074 case "SET_THREAD_TURN_CHECKPOINT_DIFFS":
1075 return {
1076 ...state,
1077 threads: updateThread(state.threads, action.threadId, (t) => {
1078 let hasUpdates = false;
1079 const updatedSummaries = t.turnDiffSummaries.map((summary) => {
1080 const checkpointDiff = action.checkpointDiffByTurnId[summary.turnId];
1081 if (checkpointDiff === undefined) {
1082 return summary;
1083 }
1084
1085 const files = deriveTurnDiffFilesFromUnifiedDiff(checkpointDiff);
1086 const filesUnchanged =
1087 files.length === summary.files.length &&
1088 files.every((file, index) => {
1089 const existing = summary.files[index];
1090 return (
1091 existing !== undefined &&
1092 existing.path === file.path &&
1093 existing.diff === file.diff &&
1094 existing.kind === file.kind &&
1095 existing.additions === file.additions &&
1096 existing.deletions === file.deletions
1097 );
1098 });
1099 if (
1100 summary.unifiedDiff === checkpointDiff &&
1101 filesUnchanged &&
1102 summary.checkpointDiffLoaded
1103 ) {
1104 return summary;
1105 }
1106
1107 hasUpdates = true;
1108 return {
1109 ...summary,
1110 unifiedDiff: checkpointDiff,
1111 files,
1112 checkpointDiffLoaded: true,
1113 };
1114 });
1115
1116 if (!hasUpdates) {
1117 return t;
1118 }
1119
1120 return {
1121 ...t,
1122 turnDiffSummaries: updatedSummaries,
1123 };
1124 }),
1125 };
1126
11271056 case "SET_THREAD_BRANCH": {
11281057 return {
11291058 ...state,
apps/web/src/types.ts-3
......@@ -40,7 +40,6 @@ export interface ChatMessage {
4040export interface TurnDiffFileChange {
4141 path: string;
4242 kind?: string | undefined;
43 diff?: string | undefined;
4443 additions?: number | undefined;
4544 deletions?: number | undefined;
4645}
......@@ -50,10 +49,8 @@ export interface TurnDiffSummary {
5049 completedAt: string;
5150 status?: string | undefined;
5251 files: TurnDiffFileChange[];
53 unifiedDiff?: string | undefined;
5452 assistantMessageId?: string | undefined;
5553 checkpointTurnCount?: number | undefined;
56 checkpointDiffLoaded?: boolean | undefined;
5754}
5855
5956export interface Project {