| author | |
| committer | |
| log | 9a8a5b3935fc9027de2af5b109a689038d6c14d1 |
| tree | 0b3e9484cf7fac322b754fb919456b7ff536bd8a |
| parent | 58a8d653a8f16d3c0e8d8ff86728fcf4d28e32fb |
- 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 behavior11 files changed, 197 insertions(+), 348 deletions(-)
apps/web/src/components/ChatView.tsx+85-133| ... | ... | @@ -26,12 +26,11 @@ import { |
| 26 | 26 | useRef, |
| 27 | 27 | useState, |
| 28 | 28 | } from "react"; |
| 29 | import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 29 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 30 | 30 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 31 | 31 | import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual"; |
| 32 | 32 | import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; |
| 33 | 33 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; |
| 34 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; | |
| 35 | 34 | import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; |
| 36 | 35 | |
| 37 | 36 | import { isElectron } from "../env"; |
| ... | ... | @@ -50,7 +49,6 @@ import { |
| 50 | 49 | resolveModelSlug, |
| 51 | 50 | } from "../model-logic"; |
| 52 | 51 | import { |
| 53 | countDiffStat, | |
| 54 | 52 | derivePendingApprovals, |
| 55 | 53 | derivePhase, |
| 56 | 54 | deriveTimelineEntries, |
| ... | ... | @@ -380,6 +378,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 380 | 378 | const dragDepthRef = useRef(0); |
| 381 | 379 | const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); |
| 382 | 380 | const checkpointHydrationSessionRequestRef = useRef(new Set<string>()); |
| 381 | const checkpointTurnCountSyncFingerprintRef = useRef(new Map<string, string>()); | |
| 383 | 382 | |
| 384 | 383 | const activeThread = state.threads.find((t) => t.id === threadId); |
| 385 | 384 | const activeThreadId = activeThread?.id ?? null; |
| ... | ... | @@ -492,68 +491,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 492 | 491 | }); |
| 493 | 492 | }, [activeThreadId, dispatch, inferredCheckpointTurnCountByTurnId, turnDiffSummaries]); |
| 494 | 493 | |
| 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 | ||
| 557 | 494 | const completionSummary = useMemo(() => { |
| 558 | 495 | if (!activeThread?.latestTurnStartedAt) return null; |
| 559 | 496 | if (!activeThread.latestTurnCompletedAt) return null; |
| ... | ... | @@ -1407,6 +1344,53 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1407 | 1344 | focusComposer(); |
| 1408 | 1345 | }; |
| 1409 | 1346 | |
| 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 | ||
| 1410 | 1394 | const ensureSession = useCallback( |
| 1411 | 1395 | async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => { |
| 1412 | 1396 | if (!api || !activeThread || !activeProject) return null; |
| ... | ... | @@ -1418,6 +1402,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1418 | 1402 | : sessionThreadId === activeThread.codexThreadId |
| 1419 | 1403 | ? "resumed" |
| 1420 | 1404 | : "fallback_new"; |
| 1405 | await syncCheckpointTurnCounts({ | |
| 1406 | threadId: activeThread.id, | |
| 1407 | sessionId: activeThread.session.sessionId, | |
| 1408 | threadRuntimeId: sessionThreadId, | |
| 1409 | }); | |
| 1421 | 1410 | return { |
| 1422 | 1411 | sessionId: activeThread.session.sessionId, |
| 1423 | 1412 | resolvedThreadId: sessionThreadId, |
| ... | ... | @@ -1448,6 +1437,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1448 | 1437 | : resolvedThreadId === priorCodexThreadId |
| 1449 | 1438 | ? "resumed" |
| 1450 | 1439 | : "fallback_new"; |
| 1440 | await syncCheckpointTurnCounts({ | |
| 1441 | threadId: activeThread.id, | |
| 1442 | sessionId: session.sessionId, | |
| 1443 | threadRuntimeId: resolvedThreadId, | |
| 1444 | }); | |
| 1451 | 1445 | return { |
| 1452 | 1446 | sessionId: session.sessionId, |
| 1453 | 1447 | resolvedThreadId, |
| ... | ... | @@ -1472,6 +1466,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1472 | 1466 | runtimeApprovalPolicy, |
| 1473 | 1467 | runtimeSandboxMode, |
| 1474 | 1468 | selectedModel, |
| 1469 | syncCheckpointTurnCounts, | |
| 1475 | 1470 | ], |
| 1476 | 1471 | ); |
| 1477 | 1472 | |
| ... | ... | @@ -1480,22 +1475,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1480 | 1475 | if (phase === "running" || isSending || isConnecting || isRevertingCheckpoint) return; |
| 1481 | 1476 | const hasThreadIdentity = Boolean(activeThreadRuntimeId); |
| 1482 | 1477 | 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; | |
| 1499 | 1479 | |
| 1500 | 1480 | const requestKey = `${activeThreadId}:${activeThreadRuntimeId ?? "none"}`; |
| 1501 | 1481 | if (checkpointHydrationSessionRequestRef.current.has(requestKey)) { |
| ... | ... | @@ -1522,9 +1502,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1522 | 1502 | isSending, |
| 1523 | 1503 | phase, |
| 1524 | 1504 | setThreadError, |
| 1525 | state.diffOpen, | |
| 1526 | state.diffThreadId, | |
| 1527 | state.diffTurnId, | |
| 1528 | 1505 | turnDiffSummaries, |
| 1529 | 1506 | ]); |
| 1530 | 1507 | |
| ... | ... | @@ -2739,50 +2716,32 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2739 | 2716 | row.message.id, |
| 2740 | 2717 | ); |
| 2741 | 2718 | 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); | |
| 2768 | 2732 | return ( |
| 2769 | 2733 | <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5"> |
| 2770 | 2734 | <div className="mb-1.5 flex items-center justify-between gap-2"> |
| 2771 | 2735 | <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> |
| 2772 | 2736 | <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 | )} | |
| 2786 | 2745 | </p> |
| 2787 | 2746 | <Button |
| 2788 | 2747 | type="button" |
| ... | ... | @@ -2795,11 +2754,6 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2795 | 2754 | View diff |
| 2796 | 2755 | </Button> |
| 2797 | 2756 | </div> |
| 2798 | {isCheckpointDiffLoading && ( | |
| 2799 | <p className="mb-1.5 text-[11px] text-muted-foreground/70"> | |
| 2800 | Loading checkpoint diff... | |
| 2801 | </p> | |
| 2802 | )} | |
| 2803 | 2757 | {turnSummary.files.length > 0 ? ( |
| 2804 | 2758 | <div className="flex flex-wrap gap-1.5"> |
| 2805 | 2759 | {turnSummary.files.map((file) => ( |
| ... | ... | @@ -2817,9 +2771,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2817 | 2771 | additions: file.additions, |
| 2818 | 2772 | deletions: file.deletions, |
| 2819 | 2773 | } |
| 2820 | : file.diff | |
| 2821 | ? countDiffStat(file.diff) | |
| 2822 | : null; | |
| 2774 | : null; | |
| 2823 | 2775 | if (!stat) { |
| 2824 | 2776 | return file.path; |
| 2825 | 2777 | } |
| ... | ... | @@ -2841,11 +2793,11 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2841 | 2793 | </button> |
| 2842 | 2794 | ))} |
| 2843 | 2795 | </div> |
| 2844 | ) : !isCheckpointDiffLoading ? ( | |
| 2796 | ) : ( | |
| 2845 | 2797 | <p className="text-[11px] text-muted-foreground/70"> |
| 2846 | 2798 | No changed files. |
| 2847 | 2799 | </p> |
| 2848 | ) : null} | |
| 2800 | )} | |
| 2849 | 2801 | </div> |
| 2850 | 2802 | ); |
| 2851 | 2803 | })()} |
apps/web/src/components/DiffPanel.tsx+3-96| ... | ... | @@ -5,11 +5,11 @@ import { |
| 5 | 5 | Virtualizer, |
| 6 | 6 | WorkerPoolContextProvider, |
| 7 | 7 | } from "@pierre/diffs/react"; |
| 8 | import { useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 8 | import { useQuery } from "@tanstack/react-query"; | |
| 9 | 9 | import { useParams } from "@tanstack/react-router"; |
| 10 | 10 | import { Columns2Icon, Rows3Icon } from "lucide-react"; |
| 11 | 11 | import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; |
| 12 | import { checkpointDiffQueryOptions, providerQueryKeys } from "~/lib/providerReactQuery"; | |
| 12 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; | |
| 13 | 13 | import { cn } from "~/lib/utils"; |
| 14 | 14 | import { isElectron } from "../env"; |
| 15 | 15 | import { useNativeApi } from "../hooks/useNativeApi"; |
| ... | ... | @@ -103,7 +103,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 103 | 103 | const api = useNativeApi(); |
| 104 | 104 | const { resolvedTheme } = useTheme(); |
| 105 | 105 | const { state, dispatch } = useStore(); |
| 106 | const queryClient = useQueryClient(); | |
| 107 | 106 | const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked"); |
| 108 | 107 | const patchViewportRef = useRef<HTMLDivElement>(null); |
| 109 | 108 | const params = useParams({ strict: false }); |
| ... | ... | @@ -193,99 +192,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 193 | 192 | ? "Failed to load checkpoint diff." |
| 194 | 193 | : null; |
| 195 | 194 | |
| 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; | |
| 289 | 196 | const renderablePatch = useMemo(() => getRenderablePatch(selectedPatch), [selectedPatch]); |
| 290 | 197 | const renderableFiles = useMemo(() => { |
| 291 | 198 | if (!renderablePatch || renderablePatch.kind !== "files") { |
apps/web/src/lib/providerReactQuery.test.ts+50| ... | ... | @@ -52,4 +52,54 @@ describe("checkpointDiffQueryOptions", () => { |
| 52 | 52 | toTurnCount: 4, |
| 53 | 53 | }); |
| 54 | 54 | }); |
| 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 | }); | |
| 55 | 105 | }); |
apps/web/src/lib/providerReactQuery.ts+29-2| ... | ... | @@ -23,6 +23,25 @@ export const providerQueryKeys = { |
| 23 | 23 | ] as const, |
| 24 | 24 | }; |
| 25 | 25 | |
| 26 | function 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 | ||
| 36 | function 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 | ||
| 26 | 45 | export function checkpointDiffQueryOptions( |
| 27 | 46 | api: NativeApi | undefined, |
| 28 | 47 | input: CheckpointDiffQueryInput, |
| ... | ... | @@ -54,7 +73,15 @@ export function checkpointDiffQueryOptions( |
| 54 | 73 | }, |
| 55 | 74 | enabled: !!api && !!input.sessionId && hasValidRange, |
| 56 | 75 | 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)), | |
| 59 | 86 | }); |
| 60 | 87 | } |
apps/web/src/persistenceSchema.test.ts+1-4| ... | ... | @@ -263,7 +263,7 @@ describe("hydratePersistedState", () => { |
| 263 | 263 | expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); |
| 264 | 264 | }); |
| 265 | 265 | |
| 266 | it("hydrates persisted turn diff summaries and clears persisted loaded flags", () => { | |
| 266 | it("hydrates persisted turn diff summaries as metadata-only entries", () => { | |
| 267 | 267 | const payload = JSON.stringify({ |
| 268 | 268 | version: 7, |
| 269 | 269 | runtimeMode: "full-access", |
| ... | ... | @@ -290,7 +290,6 @@ describe("hydratePersistedState", () => { |
| 290 | 290 | turnId: "turn-1", |
| 291 | 291 | completedAt: "2026-02-08T10:05:00.000Z", |
| 292 | 292 | checkpointTurnCount: 1, |
| 293 | checkpointDiffLoaded: true, | |
| 294 | 293 | files: [ |
| 295 | 294 | { |
| 296 | 295 | path: "src/app.ts", |
| ... | ... | @@ -421,10 +420,8 @@ describe("toPersistedState", () => { |
| 421 | 420 | kind: "modified", |
| 422 | 421 | additions: 3, |
| 423 | 422 | deletions: 1, |
| 424 | diff: "diff --git a/src/app.ts b/src/app.ts", | |
| 425 | 423 | }, |
| 426 | 424 | ], |
| 427 | unifiedDiff: "diff --git a/src/app.ts b/src/app.ts", | |
| 428 | 425 | }, |
| 429 | 426 | ], |
| 430 | 427 | }; |
apps/web/src/persistenceSchema.ts-1| ... | ... | @@ -61,7 +61,6 @@ const persistedTurnDiffSummarySchema = z.object({ |
| 61 | 61 | files: z.array(persistedTurnDiffFileChangeSchema), |
| 62 | 62 | assistantMessageId: z.string().min(1).optional(), |
| 63 | 63 | checkpointTurnCount: z.number().int().min(0).optional(), |
| 64 | checkpointDiffLoaded: z.boolean().optional(), | |
| 65 | 64 | }); |
| 66 | 65 | |
| 67 | 66 | const persistedThreadSchema = z.object({ |
apps/web/src/session-logic.test.ts+6-6| ... | ... | @@ -493,11 +493,11 @@ describe("deriveTurnDiffSummaries", () => { |
| 493 | 493 | expect(summaries).toHaveLength(1); |
| 494 | 494 | expect(summaries[0]?.turnId).toBe("turn-1"); |
| 495 | 495 | expect(summaries[0]?.assistantMessageId).toBe("msg-1"); |
| 496 | expect(summaries[0]?.unifiedDiff).toContain("diff --git a/src/a.ts b/src/a.ts"); | |
| 497 | 496 | expect(summaries[0]?.files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]); |
| 498 | 497 | expect(summaries[0]?.files[0]?.kind).toBe("modified"); |
| 499 | 498 | expect(summaries[0]?.files[0]?.additions).toBe(1); |
| 500 | 499 | expect(summaries[0]?.files[0]?.deletions).toBe(1); |
| 500 | expect("unifiedDiff" in (summaries[0] as Record<string, unknown>)).toBe(false); | |
| 501 | 501 | }); |
| 502 | 502 | |
| 503 | 503 | it("includes completed turns even when no fileChange tool events were emitted", () => { |
| ... | ... | @@ -537,9 +537,10 @@ describe("deriveTurnDiffSummaries", () => { |
| 537 | 537 | payload: { |
| 538 | 538 | diff: [ |
| 539 | 539 | "diff --git a/src/example.ts b/src/example.ts", |
| 540 | "@@ -1 +1 @@", | |
| 540 | "@@ -1 +1,2 @@", | |
| 541 | 541 | "-old", |
| 542 | 542 | "+older", |
| 543 | "+extra", | |
| 543 | 544 | ].join("\n"), |
| 544 | 545 | }, |
| 545 | 546 | }), |
| ... | ... | @@ -572,8 +573,9 @@ describe("deriveTurnDiffSummaries", () => { |
| 572 | 573 | ]); |
| 573 | 574 | |
| 574 | 575 | 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); | |
| 577 | 579 | }); |
| 578 | 580 | }); |
| 579 | 581 | |
| ... | ... | @@ -593,8 +595,6 @@ describe("deriveTurnDiffFilesFromUnifiedDiff", () => { |
| 593 | 595 | ); |
| 594 | 596 | |
| 595 | 597 | 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"); | |
| 598 | 598 | expect(files[0]?.additions).toBe(1); |
| 599 | 599 | expect(files[0]?.deletions).toBe(1); |
| 600 | 600 | }); |
apps/web/src/session-logic.ts+4-7| ... | ... | @@ -453,7 +453,7 @@ interface MutableTurnDiffSummary { |
| 453 | 453 | completedAt: string | undefined; |
| 454 | 454 | status: string | undefined; |
| 455 | 455 | assistantMessageId: string | undefined; |
| 456 | unifiedDiff: string | undefined; | |
| 456 | hasTurnDiffUpdate: boolean; | |
| 457 | 457 | filesByPath: Map<string, TurnDiffFileChange>; |
| 458 | 458 | } |
| 459 | 459 | |
| ... | ... | @@ -485,7 +485,6 @@ function parseFileChangeEntriesFromEvent(event: ProviderEvent): TurnDiffFileChan |
| 485 | 485 | parsed.push({ |
| 486 | 486 | path, |
| 487 | 487 | ...(kind ? { kind } : {}), |
| 488 | ...(diff ? { diff } : {}), | |
| 489 | 488 | ...(additions !== undefined ? { additions } : stat ? { additions: stat.additions } : {}), |
| 490 | 489 | ...(deletions !== undefined ? { deletions } : stat ? { deletions: stat.deletions } : {}), |
| 491 | 490 | }); |
| ... | ... | @@ -565,7 +564,6 @@ export function deriveTurnDiffFilesFromUnifiedDiff(diff: string): TurnDiffFileCh |
| 565 | 564 | const stat = countDiffStat(fileDiff); |
| 566 | 565 | return { |
| 567 | 566 | path, |
| 568 | diff: fileDiff, | |
| 569 | 567 | additions: stat.additions, |
| 570 | 568 | deletions: stat.deletions, |
| 571 | 569 | }; |
| ... | ... | @@ -645,7 +643,7 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar |
| 645 | 643 | completedAt: undefined, |
| 646 | 644 | status: undefined, |
| 647 | 645 | assistantMessageId: undefined, |
| 648 | unifiedDiff: undefined, | |
| 646 | hasTurnDiffUpdate: false, | |
| 649 | 647 | filesByPath: new Map<string, TurnDiffFileChange>(), |
| 650 | 648 | }; |
| 651 | 649 | byTurnId.set(turnId, next); |
| ... | ... | @@ -677,10 +675,10 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar |
| 677 | 675 | } |
| 678 | 676 | } |
| 679 | 677 | |
| 680 | if (event.method === "turn/diff/updated" && summary.unifiedDiff === undefined) { | |
| 678 | if (event.method === "turn/diff/updated" && !summary.hasTurnDiffUpdate) { | |
| 679 | summary.hasTurnDiffUpdate = true; | |
| 681 | 680 | const diff = normalizeDetail(asString(asObject(event.payload)?.diff)); |
| 682 | 681 | if (diff) { |
| 683 | summary.unifiedDiff = diff; | |
| 684 | 682 | for (const file of deriveTurnDiffFilesFromUnifiedDiff(diff)) { |
| 685 | 683 | mergeTurnDiffFileChange(summary.filesByPath, file); |
| 686 | 684 | } |
| ... | ... | @@ -703,7 +701,6 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar |
| 703 | 701 | files: Array.from(summary.filesByPath.values()).toSorted((a, b) => |
| 704 | 702 | a.path.localeCompare(b.path), |
| 705 | 703 | ), |
| 706 | ...(summary.unifiedDiff ? { unifiedDiff: summary.unifiedDiff } : {}), | |
| 707 | 704 | ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}), |
| 708 | 705 | }); |
| 709 | 706 | } |
apps/web/src/store.test.ts+18-24| ... | ... | @@ -911,7 +911,7 @@ describe("store reducer thread continuity", () => { |
| 911 | 911 | expect(next.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn_1"]); |
| 912 | 912 | }); |
| 913 | 913 | |
| 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", () => { | |
| 915 | 915 | const state = makeState( |
| 916 | 916 | makeThread({ |
| 917 | 917 | turnDiffSummaries: [ |
| ... | ... | @@ -921,11 +921,10 @@ describe("store reducer thread continuity", () => { |
| 921 | 921 | files: [ |
| 922 | 922 | { |
| 923 | 923 | 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, | |
| 925 | 926 | }, |
| 926 | 927 | ], |
| 927 | unifiedDiff: "diff --git a/src/from-checkpoint.ts b/src/from-checkpoint.ts", | |
| 928 | checkpointDiffLoaded: true, | |
| 929 | 928 | }, |
| 930 | 929 | ], |
| 931 | 930 | }), |
| ... | ... | @@ -945,11 +944,11 @@ describe("store reducer thread continuity", () => { |
| 945 | 944 | expect(next.threads[0]?.turnDiffSummaries[0]?.files.map((file) => file.path)).toEqual([ |
| 946 | 945 | "src/from-checkpoint.ts", |
| 947 | 946 | ]); |
| 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); | |
| 950 | 949 | }); |
| 951 | 950 | |
| 952 | it("updates turn summaries from checkpoint diffs and marks them as loaded", () => { | |
| 951 | it("updates checkpoint turn counts from authoritative checkpoint mappings", () => { | |
| 953 | 952 | const state = makeState( |
| 954 | 953 | makeThread({ |
| 955 | 954 | turnDiffSummaries: [ |
| ... | ... | @@ -959,31 +958,26 @@ describe("store reducer thread continuity", () => { |
| 959 | 958 | files: [], |
| 960 | 959 | checkpointTurnCount: 1, |
| 961 | 960 | }, |
| 961 | { | |
| 962 | turnId: "turn-2", | |
| 963 | completedAt: "2026-02-09T00:00:04.000Z", | |
| 964 | files: [], | |
| 965 | checkpointTurnCount: 2, | |
| 966 | }, | |
| 962 | 967 | ], |
| 963 | 968 | }), |
| 964 | 969 | ); |
| 965 | 970 | |
| 966 | 971 | const next = reducer(state, { |
| 967 | type: "SET_THREAD_TURN_CHECKPOINT_DIFFS", | |
| 972 | type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", | |
| 968 | 973 | 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, | |
| 980 | 977 | }, |
| 981 | 978 | }); |
| 982 | 979 | |
| 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); | |
| 988 | 982 | }); |
| 989 | 983 | }); |
apps/web/src/store.ts+1-72| ... | ... | @@ -15,7 +15,6 @@ import { |
| 15 | 15 | applyEventToMessages, |
| 16 | 16 | asObject, |
| 17 | 17 | asString, |
| 18 | deriveTurnDiffFilesFromUnifiedDiff, | |
| 19 | 18 | deriveTurnDiffSummaries, |
| 20 | 19 | inferCheckpointTurnCountByTurnId, |
| 21 | 20 | evolveSession, |
| ... | ... | @@ -94,11 +93,6 @@ type Action = |
| 94 | 93 | threadId: string; |
| 95 | 94 | checkpointTurnCountByTurnId: Record<string, number>; |
| 96 | 95 | } |
| 97 | | { | |
| 98 | type: "SET_THREAD_TURN_CHECKPOINT_DIFFS"; | |
| 99 | threadId: string; | |
| 100 | checkpointDiffByTurnId: Record<string, string>; | |
| 101 | } | |
| 102 | 96 | | { |
| 103 | 97 | type: "SET_THREAD_BRANCH"; |
| 104 | 98 | threadId: string; |
| ... | ... | @@ -236,21 +230,12 @@ function mergeTurnDiffSummaries( |
| 236 | 230 | return summary; |
| 237 | 231 | } |
| 238 | 232 | |
| 239 | const prefersCheckpointDiff = previous.checkpointDiffLoaded || summary.checkpointDiffLoaded; | |
| 240 | 233 | 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; | |
| 249 | 235 | |
| 250 | 236 | return { |
| 251 | 237 | ...summary, |
| 252 | 238 | files, |
| 253 | ...(unifiedDiff !== undefined ? { unifiedDiff } : {}), | |
| 254 | 239 | ...(summary.assistantMessageId |
| 255 | 240 | ? {} |
| 256 | 241 | : previous.assistantMessageId |
| ... | ... | @@ -261,9 +246,6 @@ function mergeTurnDiffSummaries( |
| 261 | 246 | : typeof previous.checkpointTurnCount === "number" |
| 262 | 247 | ? { checkpointTurnCount: previous.checkpointTurnCount } |
| 263 | 248 | : {}), |
| 264 | ...(summary.checkpointDiffLoaded || previous.checkpointDiffLoaded | |
| 265 | ? { checkpointDiffLoaded: true } | |
| 266 | : {}), | |
| 267 | 249 | }; |
| 268 | 250 | }); |
| 269 | 251 | |
| ... | ... | @@ -1071,59 +1053,6 @@ export function reducer(state: AppState, action: Action): AppState { |
| 1071 | 1053 | }), |
| 1072 | 1054 | }; |
| 1073 | 1055 | |
| 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 | ||
| 1127 | 1056 | case "SET_THREAD_BRANCH": { |
| 1128 | 1057 | return { |
| 1129 | 1058 | ...state, |
apps/web/src/types.ts-3| ... | ... | @@ -40,7 +40,6 @@ export interface ChatMessage { |
| 40 | 40 | export interface TurnDiffFileChange { |
| 41 | 41 | path: string; |
| 42 | 42 | kind?: string | undefined; |
| 43 | diff?: string | undefined; | |
| 44 | 43 | additions?: number | undefined; |
| 45 | 44 | deletions?: number | undefined; |
| 46 | 45 | } |
| ... | ... | @@ -50,10 +49,8 @@ export interface TurnDiffSummary { |
| 50 | 49 | completedAt: string; |
| 51 | 50 | status?: string | undefined; |
| 52 | 51 | files: TurnDiffFileChange[]; |
| 53 | unifiedDiff?: string | undefined; | |
| 54 | 52 | assistantMessageId?: string | undefined; |
| 55 | 53 | checkpointTurnCount?: number | undefined; |
| 56 | checkpointDiffLoaded?: boolean | undefined; | |
| 57 | 54 | } |
| 58 | 55 | |
| 59 | 56 | export interface Project { |