diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2bccb9ad345429137f59f5b99f97a5f6c9f25dd0..d70bd53f1e54ec87cd700f8a4cd2fc0cdca5e4de 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -26,12 +26,11 @@ import { useRef, useState, } from "react"; -import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual"; 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"; @@ -50,7 +49,6 @@ import { resolveModelSlug, } from "../model-logic"; import { - countDiffStat, derivePendingApprovals, derivePhase, deriveTimelineEntries, @@ -380,6 +378,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); const checkpointHydrationSessionRequestRef = useRef(new Set()); + const checkpointTurnCountSyncFingerprintRef = useRef(new Map()); const activeThread = state.threads.find((t) => t.id === threadId); const activeThreadId = activeThread?.id ?? null; @@ -492,68 +491,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }); }, [activeThreadId, dispatch, inferredCheckpointTurnCountByTurnId, turnDiffSummaries]); - const checkpointDiffHydrationTargets = useMemo(() => { - if (!api || !activeThreadId || !activeSessionId || turnDiffSummaries.length === 0) { - return []; - } - return turnDiffSummaries.flatMap((summary) => { - const turnCount = inferredCheckpointTurnCountByTurnId[summary.turnId]; - if (summary.checkpointDiffLoaded || typeof turnCount !== "number") { - return []; - } - return [ - { - turnId: summary.turnId, - fromTurnCount: Math.max(0, turnCount - 1), - toTurnCount: turnCount, - }, - ]; - }); - }, [ - activeSessionId, - activeThreadId, - api, - inferredCheckpointTurnCountByTurnId, - turnDiffSummaries, - ]); - - const checkpointDiffHydrationQueries = useQueries({ - queries: checkpointDiffHydrationTargets.map((target) => - checkpointDiffQueryOptions(api, { - sessionId: activeSessionId, - threadRuntimeId: activeThreadRuntimeId, - fromTurnCount: target.fromTurnCount, - toTurnCount: target.toTurnCount, - cacheScope: `turn:${target.turnId}`, - }), - ), - }); - - 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; @@ -1407,6 +1344,53 @@ export default function ChatView({ threadId }: ChatViewProps) { focusComposer(); }; + const syncCheckpointTurnCounts = useCallback( + async (input: { + threadId: string; + sessionId: string; + threadRuntimeId: string | null; + }): Promise => { + if (!api || turnDiffSummaries.length === 0) { + return; + } + + const turnIds = turnDiffSummaries.map((summary) => summary.turnId); + if (turnIds.length === 0) { + return; + } + + const syncKey = `${input.threadId}:${input.sessionId}:${input.threadRuntimeId ?? "none"}`; + const turnFingerprint = turnIds.join(","); + if (checkpointTurnCountSyncFingerprintRef.current.get(syncKey) === turnFingerprint) { + return; + } + checkpointTurnCountSyncFingerprintRef.current.set(syncKey, turnFingerprint); + + try { + const result = await api.providers.listCheckpoints({ + sessionId: input.sessionId, + }); + const turnIdSet = new Set(turnIds); + const checkpointTurnCountByTurnId = Object.fromEntries( + result.checkpoints + .filter((checkpoint) => checkpoint.turnCount > 0 && turnIdSet.has(checkpoint.id)) + .map((checkpoint) => [checkpoint.id, checkpoint.turnCount] as const), + ); + if (Object.keys(checkpointTurnCountByTurnId).length === 0) { + return; + } + dispatch({ + type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", + threadId: input.threadId, + checkpointTurnCountByTurnId, + }); + } catch { + checkpointTurnCountSyncFingerprintRef.current.delete(syncKey); + } + }, + [api, dispatch, turnDiffSummaries], + ); + const ensureSession = useCallback( async (cwdOverride?: string): Promise => { if (!api || !activeThread || !activeProject) return null; @@ -1418,6 +1402,11 @@ export default function ChatView({ threadId }: ChatViewProps) { : sessionThreadId === activeThread.codexThreadId ? "resumed" : "fallback_new"; + await syncCheckpointTurnCounts({ + threadId: activeThread.id, + sessionId: activeThread.session.sessionId, + threadRuntimeId: sessionThreadId, + }); return { sessionId: activeThread.session.sessionId, resolvedThreadId: sessionThreadId, @@ -1448,6 +1437,11 @@ export default function ChatView({ threadId }: ChatViewProps) { : resolvedThreadId === priorCodexThreadId ? "resumed" : "fallback_new"; + await syncCheckpointTurnCounts({ + threadId: activeThread.id, + sessionId: session.sessionId, + threadRuntimeId: resolvedThreadId, + }); return { sessionId: session.sessionId, resolvedThreadId, @@ -1472,6 +1466,7 @@ export default function ChatView({ threadId }: ChatViewProps) { runtimeApprovalPolicy, runtimeSandboxMode, selectedModel, + syncCheckpointTurnCounts, ], ); @@ -1480,22 +1475,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (phase === "running" || isSending || isConnecting || isRevertingCheckpoint) return; const hasThreadIdentity = Boolean(activeThreadRuntimeId); if (!hasThreadIdentity) return; - const selectedDiffTurnId = - state.diffOpen && state.diffThreadId === activeThreadId ? state.diffTurnId : null; - const selectedDiffTurn = - selectedDiffTurnId === null - ? undefined - : (turnDiffSummaries.find((summary) => summary.turnId === selectedDiffTurnId) ?? - turnDiffSummaries[0]); - const selectedTurnMissingPatchBody = Boolean( - selectedDiffTurn && - !selectedDiffTurn.unifiedDiff && - selectedDiffTurn.files.every((file) => !file.diff), - ); - const hasPendingDiffHydration = - turnDiffSummaries.some((summary) => !summary.checkpointDiffLoaded) || - selectedTurnMissingPatchBody; - if (!hasPendingDiffHydration) return; + if (turnDiffSummaries.length === 0) return; const requestKey = `${activeThreadId}:${activeThreadRuntimeId ?? "none"}`; if (checkpointHydrationSessionRequestRef.current.has(requestKey)) { @@ -1522,9 +1502,6 @@ export default function ChatView({ threadId }: ChatViewProps) { isSending, phase, setThreadError, - state.diffOpen, - state.diffThreadId, - state.diffTurnId, turnDiffSummaries, ]); @@ -2739,50 +2716,32 @@ const MessagesTimeline = memo(function MessagesTimeline({ row.message.id, ); if (!turnSummary) return null; - const isCheckpointDiffLoading = - !turnSummary.checkpointDiffLoaded && turnSummary.files.length === 0; - const summaryStat = turnSummary.unifiedDiff - ? countDiffStat(turnSummary.unifiedDiff) - : turnSummary.files.reduce( - (acc, file) => { - const next = - typeof file.additions === "number" && - typeof file.deletions === "number" - ? { additions: file.additions, deletions: file.deletions } - : file.diff - ? countDiffStat(file.diff) - : null; - if (!next) { - return acc; - } - return { - additions: acc.additions + next.additions, - deletions: acc.deletions + next.deletions, - }; - }, - { additions: 0, deletions: 0 }, - ); - const changedFileCountLabel = isCheckpointDiffLoading - ? "..." - : String(turnSummary.files.length); + const summaryStat = turnSummary.files.reduce( + (acc, file) => { + if (typeof file.additions !== "number" || typeof file.deletions !== "number") { + return acc; + } + return { + additions: acc.additions + file.additions, + deletions: acc.deletions + file.deletions, + }; + }, + { additions: 0, deletions: 0 }, + ); + const changedFileCountLabel = String(turnSummary.files.length); return (

Changed files ({changedFileCountLabel}) - {!isCheckpointDiffLoading && - (summaryStat.additions > 0 || summaryStat.deletions > 0) && ( - <> - • - - +{summaryStat.additions} - - / - - -{summaryStat.deletions} - - - )} + {(summaryStat.additions > 0 || summaryStat.deletions > 0) && ( + <> + • + +{summaryStat.additions} + / + -{summaryStat.deletions} + + )}

- {isCheckpointDiffLoading && ( -

- Loading checkpoint diff... -

- )} {turnSummary.files.length > 0 ? (
{turnSummary.files.map((file) => ( @@ -2817,9 +2771,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ additions: file.additions, deletions: file.deletions, } - : file.diff - ? countDiffStat(file.diff) - : null; + : null; if (!stat) { return file.path; } @@ -2841,11 +2793,11 @@ const MessagesTimeline = memo(function MessagesTimeline({ ))}
- ) : !isCheckpointDiffLoading ? ( + ) : (

No changed files.

- ) : null} + )}
); })()} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index bb2664564f2f1c396f5dde7686285945cc555d17..a2678e03fcd39cd083a724f01645e5a492131f5e 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -5,11 +5,11 @@ import { Virtualizer, WorkerPoolContextProvider, } from "@pierre/diffs/react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useParams } from "@tanstack/react-router"; import { Columns2Icon, Rows3Icon } from "lucide-react"; import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; -import { checkpointDiffQueryOptions, providerQueryKeys } from "~/lib/providerReactQuery"; +import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { cn } from "~/lib/utils"; import { isElectron } from "../env"; import { useNativeApi } from "../hooks/useNativeApi"; @@ -103,7 +103,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const api = useNativeApi(); const { resolvedTheme } = useTheme(); const { state, dispatch } = useStore(); - const queryClient = useQueryClient(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const patchViewportRef = useRef(null); const params = useParams({ strict: false }); @@ -193,99 +192,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { ? "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 (activeSessionId && typeof checkpointTurnCount === "number") { - const checkpointPatch = queryClient.getQueryData<{ diff: string }>( - providerQueryKeys.checkpointDiff({ - sessionId: activeSessionId, - threadRuntimeId: activeThreadRuntimeId, - fromTurnCount: Math.max(0, checkpointTurnCount - 1), - toTurnCount: checkpointTurnCount, - cacheScope: `turn:${summary.turnId}`, - }), - )?.diff; - if (checkpointPatch) { - return checkpointPatch; - } - } - if (summary.unifiedDiff) { - return summary.unifiedDiff; - } - const filePatches = summary.files - .map((file) => file.diff?.trim()) - .filter((patch): patch is string => Boolean(patch)); - if (filePatches.length === 0) { - return undefined; - } - return filePatches.join("\n\n"); - }; - - if (selectedTurn) { - return selectedTurnCheckpointDiff ?? patchForSummary(selectedTurn); - } - - if (conversationCheckpointDiff) { - return conversationCheckpointDiff; - } - - // Fallback when a conversation checkpoint diff isn't available yet: - // keep one patch per file path (latest change wins) so files aren't duplicated. - const latestPatchByPath = new Map(); - for (const summary of turnDiffSummaries) { - for (const file of summary.files) { - if (latestPatchByPath.has(file.path)) { - continue; - } - const patch = file.diff?.trim(); - if (!patch) { - continue; - } - latestPatchByPath.set(file.path, patch); - } - } - if (latestPatchByPath.size > 0) { - return Array.from(latestPatchByPath.entries()) - .toSorted(([leftPath], [rightPath]) => - leftPath.localeCompare(rightPath, undefined, { numeric: true, sensitivity: "base" }), - ) - .map(([, patch]) => patch) - .join("\n\n"); - } - - const patches = turnDiffSummaries - .toReversed() - .map((summary) => patchForSummary(summary)?.trim()) - .filter((patch): patch is string => Boolean(patch)); - if (patches.length === 0) { - return undefined; - } - return patches.join("\n\n"); - }, [ - activeSessionId, - activeThreadRuntimeId, - conversationCheckpointDiff, - inferredCheckpointTurnCountByTurnId, - queryClient, - selectedTurn, - selectedTurnCheckpointDiff, - turnDiffSummaries, - ]); + const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff; const renderablePatch = useMemo(() => getRenderablePatch(selectedPatch), [selectedPatch]); const renderableFiles = useMemo(() => { if (!renderablePatch || renderablePatch.kind !== "files") { diff --git a/apps/web/src/lib/providerReactQuery.test.ts b/apps/web/src/lib/providerReactQuery.test.ts index 3652414b371333767f863c8c509c86b2b3948a15..57d1a198ca07224d3719819b0bf836410e506b3a 100644 --- a/apps/web/src/lib/providerReactQuery.test.ts +++ b/apps/web/src/lib/providerReactQuery.test.ts @@ -52,4 +52,54 @@ describe("checkpointDiffQueryOptions", () => { toTurnCount: 4, }); }); + + it("retries checkpoint-not-ready errors longer than generic failures", () => { + const options = checkpointDiffQueryOptions(undefined, { + sessionId: "session-id", + threadRuntimeId: "thread-id", + fromTurnCount: 1, + toTurnCount: 2, + cacheScope: "turn:abc", + }); + const retry = options.retry; + + expect(retry?.(1, new Error("Checkpoint turn count 2 exceeds current turn count 1."))).toBe( + true, + ); + expect( + retry?.( + 11, + new Error("Filesystem checkpoint is unavailable for turn 2 in thread thread-1."), + ), + ).toBe(true); + expect( + retry?.( + 12, + new Error("Filesystem checkpoint is unavailable for turn 2 in thread thread-1."), + ), + ).toBe(false); + expect(retry?.(2, new Error("Something else failed."))).toBe(true); + expect(retry?.(3, new Error("Something else failed."))).toBe(false); + }); + + it("backs off longer for checkpoint-not-ready errors", () => { + const options = checkpointDiffQueryOptions(undefined, { + sessionId: "session-id", + threadRuntimeId: "thread-id", + fromTurnCount: 1, + toTurnCount: 2, + cacheScope: "turn:abc", + }); + const retryDelay = options.retryDelay; + + const checkpointDelay = retryDelay?.( + 4, + new Error("Checkpoint turn count 2 exceeds current turn count 1."), + ); + const genericDelay = retryDelay?.(4, new Error("Network failure")); + + expect(typeof checkpointDelay).toBe("number"); + expect(typeof genericDelay).toBe("number"); + expect((checkpointDelay ?? 0) > (genericDelay ?? 0)).toBe(true); + }); }); diff --git a/apps/web/src/lib/providerReactQuery.ts b/apps/web/src/lib/providerReactQuery.ts index 0ccf7ee6030754076c4fb46a1e02ac846eff9221..14529432b819e06f215d29ccc644f18a538ddb3e 100644 --- a/apps/web/src/lib/providerReactQuery.ts +++ b/apps/web/src/lib/providerReactQuery.ts @@ -23,6 +23,25 @@ export const providerQueryKeys = { ] as const, }; +function asCheckpointErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "string") { + return error; + } + return ""; +} + +function isCheckpointTemporarilyUnavailable(error: unknown): boolean { + const message = asCheckpointErrorMessage(error).toLowerCase(); + return ( + message.includes("exceeds current turn count") || + message.includes("checkpoint is unavailable for turn") || + message.includes("filesystem checkpoint is unavailable") + ); +} + export function checkpointDiffQueryOptions( api: NativeApi | undefined, input: CheckpointDiffQueryInput, @@ -54,7 +73,15 @@ export function checkpointDiffQueryOptions( }, enabled: !!api && !!input.sessionId && hasValidRange, staleTime: Infinity, - retry: 8, - retryDelay: (attempt) => Math.min(2_000, 150 * 2 ** (attempt - 1)), + retry: (failureCount, error) => { + if (isCheckpointTemporarilyUnavailable(error)) { + return failureCount < 12; + } + return failureCount < 3; + }, + retryDelay: (attempt, error) => + isCheckpointTemporarilyUnavailable(error) + ? Math.min(5_000, 250 * 2 ** (attempt - 1)) + : Math.min(1_000, 100 * 2 ** (attempt - 1)), }); } diff --git a/apps/web/src/persistenceSchema.test.ts b/apps/web/src/persistenceSchema.test.ts index 365625033dd30b3efd71f15e8750694436b23a18..448806d8e2638a45404eaa4c7f742b542335825d 100644 --- a/apps/web/src/persistenceSchema.test.ts +++ b/apps/web/src/persistenceSchema.test.ts @@ -263,7 +263,7 @@ describe("hydratePersistedState", () => { expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); }); - it("hydrates persisted turn diff summaries and clears persisted loaded flags", () => { + it("hydrates persisted turn diff summaries as metadata-only entries", () => { const payload = JSON.stringify({ version: 7, runtimeMode: "full-access", @@ -290,7 +290,6 @@ describe("hydratePersistedState", () => { turnId: "turn-1", completedAt: "2026-02-08T10:05:00.000Z", checkpointTurnCount: 1, - checkpointDiffLoaded: true, files: [ { path: "src/app.ts", @@ -421,10 +420,8 @@ describe("toPersistedState", () => { kind: "modified", additions: 3, deletions: 1, - diff: "diff --git a/src/app.ts b/src/app.ts", }, ], - unifiedDiff: "diff --git a/src/app.ts b/src/app.ts", }, ], }; diff --git a/apps/web/src/persistenceSchema.ts b/apps/web/src/persistenceSchema.ts index d2aeb3780a807e6b717ad2ef495b515b68179a2d..e1711ef6941462829a55e48a72ba6ecc049ccaa6 100644 --- a/apps/web/src/persistenceSchema.ts +++ b/apps/web/src/persistenceSchema.ts @@ -61,7 +61,6 @@ const persistedTurnDiffSummarySchema = z.object({ files: z.array(persistedTurnDiffFileChangeSchema), assistantMessageId: z.string().min(1).optional(), checkpointTurnCount: z.number().int().min(0).optional(), - checkpointDiffLoaded: z.boolean().optional(), }); const persistedThreadSchema = z.object({ diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 56ecd6afde6b0804c5d436cbec635b23c6f4e793..f4f2db9d66eaa3e5f50ad8a7009bb4d69849b1d0 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -493,11 +493,11 @@ describe("deriveTurnDiffSummaries", () => { expect(summaries).toHaveLength(1); expect(summaries[0]?.turnId).toBe("turn-1"); expect(summaries[0]?.assistantMessageId).toBe("msg-1"); - expect(summaries[0]?.unifiedDiff).toContain("diff --git a/src/a.ts b/src/a.ts"); expect(summaries[0]?.files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]); expect(summaries[0]?.files[0]?.kind).toBe("modified"); expect(summaries[0]?.files[0]?.additions).toBe(1); expect(summaries[0]?.files[0]?.deletions).toBe(1); + expect("unifiedDiff" in (summaries[0] as Record)).toBe(false); }); it("includes completed turns even when no fileChange tool events were emitted", () => { @@ -537,9 +537,10 @@ describe("deriveTurnDiffSummaries", () => { payload: { diff: [ "diff --git a/src/example.ts b/src/example.ts", - "@@ -1 +1 @@", + "@@ -1 +1,2 @@", "-old", "+older", + "+extra", ].join("\n"), }, }), @@ -572,8 +573,9 @@ describe("deriveTurnDiffSummaries", () => { ]); expect(summaries).toHaveLength(1); - expect(summaries[0]?.unifiedDiff).toContain("+newest"); - expect(summaries[0]?.unifiedDiff).not.toContain("+older"); + expect(summaries[0]?.files[0]?.path).toBe("src/example.ts"); + expect(summaries[0]?.files[0]?.additions).toBe(1); + expect(summaries[0]?.files[0]?.deletions).toBe(1); }); }); @@ -593,8 +595,6 @@ describe("deriveTurnDiffFilesFromUnifiedDiff", () => { ); expect(files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]); - expect(files[0]?.diff).toContain("diff --git a/src/a.ts b/src/a.ts"); - expect(files[1]?.diff).toContain("diff --git a/src/b.ts b/src/b.ts"); expect(files[0]?.additions).toBe(1); expect(files[0]?.deletions).toBe(1); }); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index c3fb155589a33a52a72354fae1bf0515a337ecea..01fae7bd42200fcb10545e440e191bf19308cb4c 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -453,7 +453,7 @@ interface MutableTurnDiffSummary { completedAt: string | undefined; status: string | undefined; assistantMessageId: string | undefined; - unifiedDiff: string | undefined; + hasTurnDiffUpdate: boolean; filesByPath: Map; } @@ -485,7 +485,6 @@ function parseFileChangeEntriesFromEvent(event: ProviderEvent): TurnDiffFileChan parsed.push({ path, ...(kind ? { kind } : {}), - ...(diff ? { diff } : {}), ...(additions !== undefined ? { additions } : stat ? { additions: stat.additions } : {}), ...(deletions !== undefined ? { deletions } : stat ? { deletions: stat.deletions } : {}), }); @@ -565,7 +564,6 @@ export function deriveTurnDiffFilesFromUnifiedDiff(diff: string): TurnDiffFileCh const stat = countDiffStat(fileDiff); return { path, - diff: fileDiff, additions: stat.additions, deletions: stat.deletions, }; @@ -645,7 +643,7 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar completedAt: undefined, status: undefined, assistantMessageId: undefined, - unifiedDiff: undefined, + hasTurnDiffUpdate: false, filesByPath: new Map(), }; byTurnId.set(turnId, next); @@ -677,10 +675,10 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar } } - if (event.method === "turn/diff/updated" && summary.unifiedDiff === undefined) { + if (event.method === "turn/diff/updated" && !summary.hasTurnDiffUpdate) { + summary.hasTurnDiffUpdate = true; const diff = normalizeDetail(asString(asObject(event.payload)?.diff)); if (diff) { - summary.unifiedDiff = diff; for (const file of deriveTurnDiffFilesFromUnifiedDiff(diff)) { mergeTurnDiffFileChange(summary.filesByPath, file); } @@ -703,7 +701,6 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar files: Array.from(summary.filesByPath.values()).toSorted((a, b) => a.path.localeCompare(b.path), ), - ...(summary.unifiedDiff ? { unifiedDiff: summary.unifiedDiff } : {}), ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}), }); } diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index b1095463d5ded3800c22f3771f8e9aa1ed954d3c..3c7e8690fd3ac1a3a8a7b8768dcbaccf1f0429dc 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -911,7 +911,7 @@ describe("store reducer thread continuity", () => { expect(next.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn_1"]); }); - it("keeps checkpoint-derived turn files when later events for the same turn arrive", () => { + it("keeps existing turn file metadata when later events for the same turn arrive", () => { const state = makeState( makeThread({ turnDiffSummaries: [ @@ -921,11 +921,10 @@ describe("store reducer thread continuity", () => { files: [ { path: "src/from-checkpoint.ts", - diff: "diff --git a/src/from-checkpoint.ts b/src/from-checkpoint.ts", + additions: 1, + deletions: 1, }, ], - unifiedDiff: "diff --git a/src/from-checkpoint.ts b/src/from-checkpoint.ts", - checkpointDiffLoaded: true, }, ], }), @@ -945,11 +944,11 @@ describe("store reducer thread continuity", () => { expect(next.threads[0]?.turnDiffSummaries[0]?.files.map((file) => file.path)).toEqual([ "src/from-checkpoint.ts", ]); - expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointDiffLoaded).toBe(true); - expect(next.threads[0]?.turnDiffSummaries[0]?.unifiedDiff).toContain("from-checkpoint.ts"); + expect(next.threads[0]?.turnDiffSummaries[0]?.files[0]?.additions).toBe(1); + expect(next.threads[0]?.turnDiffSummaries[0]?.files[0]?.deletions).toBe(1); }); - it("updates turn summaries from checkpoint diffs and marks them as loaded", () => { + it("updates checkpoint turn counts from authoritative checkpoint mappings", () => { const state = makeState( makeThread({ turnDiffSummaries: [ @@ -959,31 +958,26 @@ describe("store reducer thread continuity", () => { files: [], checkpointTurnCount: 1, }, + { + turnId: "turn-2", + completedAt: "2026-02-09T00:00:04.000Z", + files: [], + checkpointTurnCount: 2, + }, ], }), ); const next = reducer(state, { - type: "SET_THREAD_TURN_CHECKPOINT_DIFFS", + type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", threadId: "thread-local-1", - checkpointDiffByTurnId: { - "turn-1": [ - "diff --git a/src/a.ts b/src/a.ts", - "@@ -1 +1 @@", - "-old-a", - "+new-a", - "diff --git a/src/b.ts b/src/b.ts", - "@@ -1 +1 @@", - "-old-b", - "+new-b", - ].join("\n"), + checkpointTurnCountByTurnId: { + "turn-1": 2, + "turn-2": 3, }, }); - expect(next.threads[0]?.turnDiffSummaries[0]?.files.map((file) => file.path)).toEqual([ - "src/a.ts", - "src/b.ts", - ]); - expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointDiffLoaded).toBe(true); + expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointTurnCount).toBe(2); + expect(next.threads[0]?.turnDiffSummaries[1]?.checkpointTurnCount).toBe(3); }); }); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 265f58abbae4911c0f19f45139e2106462f0613e..d07219639ae3f2a3007d94f2a9ebb7bc195ce12c 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -15,7 +15,6 @@ import { applyEventToMessages, asObject, asString, - deriveTurnDiffFilesFromUnifiedDiff, deriveTurnDiffSummaries, inferCheckpointTurnCountByTurnId, evolveSession, @@ -94,11 +93,6 @@ type Action = threadId: string; checkpointTurnCountByTurnId: Record; } - | { - type: "SET_THREAD_TURN_CHECKPOINT_DIFFS"; - threadId: string; - checkpointDiffByTurnId: Record; - } | { type: "SET_THREAD_BRANCH"; threadId: string; @@ -236,21 +230,12 @@ function mergeTurnDiffSummaries( return summary; } - const prefersCheckpointDiff = previous.checkpointDiffLoaded || summary.checkpointDiffLoaded; const files = - prefersCheckpointDiff || - (summary.files.length === 0 && previous.files.length > 0 && summary.unifiedDiff === undefined) - ? previous.files - : summary.files; - const unifiedDiff = - prefersCheckpointDiff || summary.unifiedDiff === undefined - ? (previous.unifiedDiff ?? summary.unifiedDiff) - : summary.unifiedDiff; + summary.files.length === 0 && previous.files.length > 0 ? previous.files : summary.files; return { ...summary, files, - ...(unifiedDiff !== undefined ? { unifiedDiff } : {}), ...(summary.assistantMessageId ? {} : previous.assistantMessageId @@ -261,9 +246,6 @@ function mergeTurnDiffSummaries( : typeof previous.checkpointTurnCount === "number" ? { checkpointTurnCount: previous.checkpointTurnCount } : {}), - ...(summary.checkpointDiffLoaded || previous.checkpointDiffLoaded - ? { checkpointDiffLoaded: true } - : {}), }; }); @@ -1071,59 +1053,6 @@ export function reducer(state: AppState, action: Action): AppState { }), }; - case "SET_THREAD_TURN_CHECKPOINT_DIFFS": - return { - ...state, - threads: updateThread(state.threads, action.threadId, (t) => { - let hasUpdates = false; - const updatedSummaries = t.turnDiffSummaries.map((summary) => { - const checkpointDiff = action.checkpointDiffByTurnId[summary.turnId]; - if (checkpointDiff === undefined) { - return summary; - } - - const files = deriveTurnDiffFilesFromUnifiedDiff(checkpointDiff); - const filesUnchanged = - files.length === summary.files.length && - files.every((file, index) => { - const existing = summary.files[index]; - return ( - existing !== undefined && - existing.path === file.path && - existing.diff === file.diff && - existing.kind === file.kind && - existing.additions === file.additions && - existing.deletions === file.deletions - ); - }); - if ( - summary.unifiedDiff === checkpointDiff && - filesUnchanged && - summary.checkpointDiffLoaded - ) { - return summary; - } - - hasUpdates = true; - return { - ...summary, - unifiedDiff: checkpointDiff, - files, - checkpointDiffLoaded: true, - }; - }); - - if (!hasUpdates) { - return t; - } - - return { - ...t, - turnDiffSummaries: updatedSummaries, - }; - }), - }; - case "SET_THREAD_BRANCH": { return { ...state, diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 4f33b891f84481f4374b33f70134b6e9e385547b..e2902680bf354cb94c7940bb40000faf83cb2afd 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -40,7 +40,6 @@ export interface ChatMessage { export interface TurnDiffFileChange { path: string; kind?: string | undefined; - diff?: string | undefined; additions?: number | undefined; deletions?: number | undefined; } @@ -50,10 +49,8 @@ export interface TurnDiffSummary { completedAt: string; status?: string | undefined; files: TurnDiffFileChange[]; - unifiedDiff?: string | undefined; assistantMessageId?: string | undefined; checkpointTurnCount?: number | undefined; - checkpointDiffLoaded?: boolean | undefined; } export interface Project {