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