| author | |
| committer | |
| log | 3afbf93d5e54fec6674c62a5521f78c0ec2cf846 |
| tree | 83fcd8cb80bd6ce1ceb0458d4f8f4ee9dc011f92 |
| parent | ec5c3c32cd2acaec16f0b6b26d2f0e6bb3030e3c |
- add shared provider checkpoint diff query options and keys
- refactor `ChatView` and `DiffPanel` to fetch diffs via React Query
- persist selected-turn checkpoint diffs back into thread state for reuse9 files changed, 258 insertions(+), 194 deletions(-)
apps/server/src/filesystemCheckpointStore.ts+30-8| ... | ... | @@ -38,7 +38,11 @@ export class FilesystemCheckpointStore { |
| 38 | 38 | return result.code === 0 && result.stdout.trim() === "true"; |
| 39 | 39 | } |
| 40 | 40 | |
| 41 | async captureCheckpoint(input: { cwd: string; threadId: string; turnCount: number }): Promise<void> { | |
| 41 | async captureCheckpoint(input: { | |
| 42 | cwd: string; | |
| 43 | threadId: string; | |
| 44 | turnCount: number; | |
| 45 | }): Promise<void> { | |
| 42 | 46 | const { cwd, threadId, turnCount } = input; |
| 43 | 47 | const ref = checkpointRefForThreadTurn(threadId, turnCount); |
| 44 | 48 | |
| ... | ... | @@ -84,7 +88,11 @@ export class FilesystemCheckpointStore { |
| 84 | 88 | } |
| 85 | 89 | } |
| 86 | 90 | |
| 87 | async hasCheckpoint(input: { cwd: string; threadId: string; turnCount: number }): Promise<boolean> { | |
| 91 | async hasCheckpoint(input: { | |
| 92 | cwd: string; | |
| 93 | threadId: string; | |
| 94 | turnCount: number; | |
| 95 | }): Promise<boolean> { | |
| 88 | 96 | const { cwd, threadId, turnCount } = input; |
| 89 | 97 | const ref = checkpointRefForThreadTurn(threadId, turnCount); |
| 90 | 98 | const commit = await this.resolveCheckpointCommit(cwd, ref); |
| ... | ... | @@ -108,7 +116,11 @@ export class FilesystemCheckpointStore { |
| 108 | 116 | return true; |
| 109 | 117 | } |
| 110 | 118 | |
| 111 | async restoreCheckpoint(input: { cwd: string; threadId: string; turnCount: number }): Promise<boolean> { | |
| 119 | async restoreCheckpoint(input: { | |
| 120 | cwd: string; | |
| 121 | threadId: string; | |
| 122 | turnCount: number; | |
| 123 | }): Promise<boolean> { | |
| 112 | 124 | const { cwd, threadId, turnCount } = input; |
| 113 | 125 | const ref = checkpointRefForThreadTurn(threadId, turnCount); |
| 114 | 126 | let commitOid = await this.resolveCheckpointCommit(cwd, ref); |
| ... | ... | @@ -158,7 +170,9 @@ export class FilesystemCheckpointStore { |
| 158 | 170 | ); |
| 159 | 171 | } |
| 160 | 172 | if (!toCommitOid) { |
| 161 | throw new Error(`Filesystem checkpoint is unavailable for turn ${toTurnCount} in thread ${threadId}.`); | |
| 173 | throw new Error( | |
| 174 | `Filesystem checkpoint is unavailable for turn ${toTurnCount} in thread ${threadId}.`, | |
| 175 | ); | |
| 162 | 176 | } |
| 163 | 177 | |
| 164 | 178 | const result = await this.runGit(cwd, [ |
| ... | ... | @@ -183,16 +197,24 @@ export class FilesystemCheckpointStore { |
| 183 | 197 | return commit.length > 0 ? commit : null; |
| 184 | 198 | } |
| 185 | 199 | |
| 186 | async pruneAfterTurn(input: { cwd: string; threadId: string; maxTurnCount: number }): Promise<void> { | |
| 200 | async pruneAfterTurn(input: { | |
| 201 | cwd: string; | |
| 202 | threadId: string; | |
| 203 | maxTurnCount: number; | |
| 204 | }): Promise<void> { | |
| 187 | 205 | const { cwd, threadId, maxTurnCount } = input; |
| 188 | 206 | if (!Number.isInteger(maxTurnCount) || maxTurnCount < 0) { |
| 189 | 207 | throw new Error(`Invalid max turn count: ${maxTurnCount}`); |
| 190 | 208 | } |
| 191 | 209 | |
| 192 | 210 | const threadRefPrefix = `${CHECKPOINT_REFS_PREFIX}/${checkpointRefThreadSegment(threadId)}/turn/`; |
| 193 | const result = await this.runGit(cwd, ["for-each-ref", "--format=%(refname)", threadRefPrefix], { | |
| 194 | allowNonZeroExit: true, | |
| 195 | }); | |
| 211 | const result = await this.runGit( | |
| 212 | cwd, | |
| 213 | ["for-each-ref", "--format=%(refname)", threadRefPrefix], | |
| 214 | { | |
| 215 | allowNonZeroExit: true, | |
| 216 | }, | |
| 217 | ); | |
| 196 | 218 | if (result.code !== 0) { |
| 197 | 219 | return; |
| 198 | 220 | } |
apps/server/src/providerManager.test.ts+49-11| ... | ... | @@ -159,7 +159,10 @@ describe("ProviderManager", () => { |
| 159 | 159 | threadId: string; |
| 160 | 160 | turns: Array<{ id: string; items: unknown[] }>; |
| 161 | 161 | }>; |
| 162 | rollbackThread: (sessionId: string, numTurns: number) => Promise<{ | |
| 162 | rollbackThread: ( | |
| 163 | sessionId: string, | |
| 164 | numTurns: number, | |
| 165 | ) => Promise<{ | |
| 163 | 166 | threadId: string; |
| 164 | 167 | turns: Array<{ id: string; items: unknown[] }>; |
| 165 | 168 | }>; |
| ... | ... | @@ -253,7 +256,10 @@ describe("ProviderManager", () => { |
| 253 | 256 | internals.codex.hasSession = () => true; |
| 254 | 257 | internals.codex.readThread = async () => ({ |
| 255 | 258 | threadId: "thr_1", |
| 256 | turns: [{ id: "turn_1", items: [] }, { id: "turn_2", items: [] }], | |
| 259 | turns: [ | |
| 260 | { id: "turn_1", items: [] }, | |
| 261 | { id: "turn_2", items: [] }, | |
| 262 | ], | |
| 257 | 263 | }); |
| 258 | 264 | const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts"); |
| 259 | 265 | internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints; |
| ... | ... | @@ -330,7 +336,10 @@ describe("ProviderManager", () => { |
| 330 | 336 | ]; |
| 331 | 337 | internals.codex.readThread = async () => ({ |
| 332 | 338 | threadId: "thr_1", |
| 333 | turns: [{ id: "turn_1", items: [] }, { id: "turn_2", items: [] }], | |
| 339 | turns: [ | |
| 340 | { id: "turn_1", items: [] }, | |
| 341 | { id: "turn_2", items: [] }, | |
| 342 | ], | |
| 334 | 343 | }); |
| 335 | 344 | const isGitRepository = vi.fn(async () => true); |
| 336 | 345 | const ensureRootCheckpoint = vi.fn(async () => true); |
| ... | ... | @@ -471,19 +480,30 @@ describe("ProviderManager", () => { |
| 471 | 480 | threadId: string; |
| 472 | 481 | turns: Array<{ id: string; items: unknown[] }>; |
| 473 | 482 | }>; |
| 474 | rollbackThread: (sessionId: string, numTurns: number) => Promise<{ | |
| 483 | rollbackThread: ( | |
| 484 | sessionId: string, | |
| 485 | numTurns: number, | |
| 486 | ) => Promise<{ | |
| 475 | 487 | threadId: string; |
| 476 | 488 | turns: Array<{ id: string; items: unknown[] }>; |
| 477 | 489 | }>; |
| 478 | 490 | }; |
| 479 | 491 | filesystemCheckpointStore: { |
| 480 | hasCheckpoint: (input: { cwd: string; threadId: string; turnCount: number }) => Promise<boolean>; | |
| 492 | hasCheckpoint: (input: { | |
| 493 | cwd: string; | |
| 494 | threadId: string; | |
| 495 | turnCount: number; | |
| 496 | }) => Promise<boolean>; | |
| 481 | 497 | restoreCheckpoint: (input: { |
| 482 | 498 | cwd: string; |
| 483 | 499 | threadId: string; |
| 484 | 500 | turnCount: number; |
| 485 | 501 | }) => Promise<boolean>; |
| 486 | pruneAfterTurn: (input: { cwd: string; threadId: string; maxTurnCount: number }) => Promise<void>; | |
| 502 | pruneAfterTurn: (input: { | |
| 503 | cwd: string; | |
| 504 | threadId: string; | |
| 505 | maxTurnCount: number; | |
| 506 | }) => Promise<void>; | |
| 487 | 507 | }; |
| 488 | 508 | sessionCheckpointCwds: Map<string, string>; |
| 489 | 509 | }; |
| ... | ... | @@ -542,19 +562,30 @@ describe("ProviderManager", () => { |
| 542 | 562 | threadId: string; |
| 543 | 563 | turns: Array<{ id: string; items: unknown[] }>; |
| 544 | 564 | }>; |
| 545 | rollbackThread: (sessionId: string, numTurns: number) => Promise<{ | |
| 565 | rollbackThread: ( | |
| 566 | sessionId: string, | |
| 567 | numTurns: number, | |
| 568 | ) => Promise<{ | |
| 546 | 569 | threadId: string; |
| 547 | 570 | turns: Array<{ id: string; items: unknown[] }>; |
| 548 | 571 | }>; |
| 549 | 572 | }; |
| 550 | 573 | filesystemCheckpointStore: { |
| 551 | hasCheckpoint: (input: { cwd: string; threadId: string; turnCount: number }) => Promise<boolean>; | |
| 574 | hasCheckpoint: (input: { | |
| 575 | cwd: string; | |
| 576 | threadId: string; | |
| 577 | turnCount: number; | |
| 578 | }) => Promise<boolean>; | |
| 552 | 579 | restoreCheckpoint: (input: { |
| 553 | 580 | cwd: string; |
| 554 | 581 | threadId: string; |
| 555 | 582 | turnCount: number; |
| 556 | 583 | }) => Promise<boolean>; |
| 557 | pruneAfterTurn: (input: { cwd: string; threadId: string; maxTurnCount: number }) => Promise<void>; | |
| 584 | pruneAfterTurn: (input: { | |
| 585 | cwd: string; | |
| 586 | threadId: string; | |
| 587 | maxTurnCount: number; | |
| 588 | }) => Promise<void>; | |
| 558 | 589 | }; |
| 559 | 590 | sessionCheckpointCwds: Map<string, string>; |
| 560 | 591 | }; |
| ... | ... | @@ -609,13 +640,20 @@ describe("ProviderManager", () => { |
| 609 | 640 | threadId: string; |
| 610 | 641 | turns: Array<{ id: string; items: unknown[] }>; |
| 611 | 642 | }>; |
| 612 | rollbackThread: (sessionId: string, numTurns: number) => Promise<{ | |
| 643 | rollbackThread: ( | |
| 644 | sessionId: string, | |
| 645 | numTurns: number, | |
| 646 | ) => Promise<{ | |
| 613 | 647 | threadId: string; |
| 614 | 648 | turns: Array<{ id: string; items: unknown[] }>; |
| 615 | 649 | }>; |
| 616 | 650 | }; |
| 617 | 651 | filesystemCheckpointStore: { |
| 618 | hasCheckpoint: (input: { cwd: string; threadId: string; turnCount: number }) => Promise<boolean>; | |
| 652 | hasCheckpoint: (input: { | |
| 653 | cwd: string; | |
| 654 | threadId: string; | |
| 655 | turnCount: number; | |
| 656 | }) => Promise<boolean>; | |
| 619 | 657 | }; |
| 620 | 658 | sessionCheckpointCwds: Map<string, string>; |
| 621 | 659 | }; |
apps/server/src/providerManager.ts+3-1| ... | ... | @@ -425,7 +425,9 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 425 | 425 | return existingCwd; |
| 426 | 426 | } |
| 427 | 427 | |
| 428 | const session = this.codex.listSessions().find((candidate) => candidate.sessionId === sessionId); | |
| 428 | const session = this.codex | |
| 429 | .listSessions() | |
| 430 | .find((candidate) => candidate.sessionId === sessionId); | |
| 429 | 431 | const candidateCwds = Array.from( |
| 430 | 432 | new Set([session?.cwd, process.cwd()].filter((value): value is string => Boolean(value))), |
| 431 | 433 | ); |
apps/web/src/components/ChatView.tsx+52-52| ... | ... | @@ -27,10 +27,11 @@ import { |
| 27 | 27 | useRef, |
| 28 | 28 | useState, |
| 29 | 29 | } from "react"; |
| 30 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 30 | import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 31 | 31 | import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; |
| 32 | 32 | import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; |
| 33 | 33 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; |
| 34 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; | |
| 34 | 35 | import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; |
| 35 | 36 | |
| 36 | 37 | import { isElectron } from "../env"; |
| ... | ... | @@ -373,12 +374,11 @@ export default function ChatView() { |
| 373 | 374 | const composerImagesRef = useRef<ComposerImageAttachment[]>([]); |
| 374 | 375 | const dragDepthRef = useRef(0); |
| 375 | 376 | const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); |
| 376 | const checkpointDiffRequestsRef = useRef(new Set<string>()); | |
| 377 | 377 | const checkpointHydrationSessionRequestRef = useRef(new Set<string>()); |
| 378 | 378 | |
| 379 | 379 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); |
| 380 | 380 | const activeThreadId = activeThread?.id ?? null; |
| 381 | const activeSessionId = activeThread?.session?.sessionId; | |
| 381 | const activeSessionId = activeThread?.session?.sessionId ?? null; | |
| 382 | 382 | const activeThreadRuntimeId = |
| 383 | 383 | activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; |
| 384 | 384 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); |
| ... | ... | @@ -433,6 +433,7 @@ export default function ChatView() { |
| 433 | 433 | () => inferCheckpointTurnCountByTurnId(turnDiffSummaries), |
| 434 | 434 | [turnDiffSummaries], |
| 435 | 435 | ); |
| 436 | ||
| 436 | 437 | const revertTurnCountByUserMessageId = useMemo(() => { |
| 437 | 438 | const byUserMessageId = new Map<string, number>(); |
| 438 | 439 | for (let index = 0; index < timelineEntries.length; index += 1) { |
| ... | ... | @@ -496,68 +497,67 @@ export default function ChatView() { |
| 496 | 497 | }); |
| 497 | 498 | }, [activeThreadId, dispatch, inferredCheckpointTurnCountByTurnId, turnDiffSummaries]); |
| 498 | 499 | |
| 499 | useEffect(() => { | |
| 500 | const checkpointDiffHydrationTargets = useMemo(() => { | |
| 500 | 501 | if (!api || !activeThreadId || !activeSessionId || turnDiffSummaries.length === 0) { |
| 501 | return; | |
| 502 | return []; | |
| 502 | 503 | } |
| 503 | ||
| 504 | const turnSummariesNeedingDiff = turnDiffSummaries.filter( | |
| 505 | (summary) => | |
| 506 | !summary.checkpointDiffLoaded && inferredCheckpointTurnCountByTurnId[summary.turnId], | |
| 507 | ); | |
| 508 | const requestedSummaries = turnSummariesNeedingDiff.filter((summary) => { | |
| 509 | const requestKey = `${activeThreadId}:${summary.turnId}`; | |
| 510 | if (checkpointDiffRequestsRef.current.has(requestKey)) { | |
| 511 | return false; | |
| 504 | return turnDiffSummaries.flatMap((summary) => { | |
| 505 | const turnCount = inferredCheckpointTurnCountByTurnId[summary.turnId]; | |
| 506 | if (summary.checkpointDiffLoaded || typeof turnCount !== "number") { | |
| 507 | return []; | |
| 512 | 508 | } |
| 513 | checkpointDiffRequestsRef.current.add(requestKey); | |
| 514 | return true; | |
| 515 | }); | |
| 516 | if (requestedSummaries.length === 0) { | |
| 517 | return; | |
| 518 | } | |
| 519 | ||
| 520 | let cancelled = false; | |
| 521 | void Promise.all( | |
| 522 | requestedSummaries.map(async (summary) => { | |
| 523 | const turnCount = inferredCheckpointTurnCountByTurnId[summary.turnId] ?? 0; | |
| 524 | const result = await api.providers.getCheckpointDiff({ | |
| 525 | sessionId: activeSessionId, | |
| 509 | return [ | |
| 510 | { | |
| 511 | turnId: summary.turnId, | |
| 526 | 512 | fromTurnCount: Math.max(0, turnCount - 1), |
| 527 | 513 | toTurnCount: turnCount, |
| 528 | }); | |
| 529 | return [summary.turnId, result.diff] as const; | |
| 530 | }), | |
| 531 | ) | |
| 532 | .then((entries) => { | |
| 533 | if (cancelled) return; | |
| 534 | dispatch({ | |
| 535 | type: "SET_THREAD_TURN_CHECKPOINT_DIFFS", | |
| 536 | threadId: activeThreadId, | |
| 537 | checkpointDiffByTurnId: Object.fromEntries(entries), | |
| 538 | }); | |
| 539 | }) | |
| 540 | .catch(() => { | |
| 541 | if (cancelled) return; | |
| 542 | }) | |
| 543 | .finally(() => { | |
| 544 | for (const summary of requestedSummaries) { | |
| 545 | checkpointDiffRequestsRef.current.delete(`${activeThreadId}:${summary.turnId}`); | |
| 546 | } | |
| 547 | }); | |
| 548 | ||
| 549 | return () => { | |
| 550 | cancelled = true; | |
| 551 | }; | |
| 514 | }, | |
| 515 | ]; | |
| 516 | }); | |
| 552 | 517 | }, [ |
| 553 | 518 | activeSessionId, |
| 554 | 519 | activeThreadId, |
| 555 | 520 | api, |
| 556 | dispatch, | |
| 557 | 521 | inferredCheckpointTurnCountByTurnId, |
| 558 | 522 | turnDiffSummaries, |
| 559 | 523 | ]); |
| 560 | 524 | |
| 525 | const checkpointDiffHydrationQueries = useQueries({ | |
| 526 | queries: checkpointDiffHydrationTargets.map((target) => | |
| 527 | checkpointDiffQueryOptions(api, { | |
| 528 | sessionId: activeSessionId, | |
| 529 | threadRuntimeId: activeThreadRuntimeId, | |
| 530 | fromTurnCount: target.fromTurnCount, | |
| 531 | toTurnCount: target.toTurnCount, | |
| 532 | }), | |
| 533 | ), | |
| 534 | }); | |
| 535 | ||
| 536 | useEffect(() => { | |
| 537 | if (!activeThreadId || checkpointDiffHydrationTargets.length === 0) { | |
| 538 | return; | |
| 539 | } | |
| 540 | ||
| 541 | const checkpointDiffByTurnId: Record<string, string> = {}; | |
| 542 | for (let index = 0; index < checkpointDiffHydrationTargets.length; index += 1) { | |
| 543 | const target = checkpointDiffHydrationTargets[index]; | |
| 544 | const query = checkpointDiffHydrationQueries[index]; | |
| 545 | const diff = query?.data?.diff; | |
| 546 | if (!target || !diff) { | |
| 547 | continue; | |
| 548 | } | |
| 549 | checkpointDiffByTurnId[target.turnId] = diff; | |
| 550 | } | |
| 551 | if (Object.keys(checkpointDiffByTurnId).length === 0) { | |
| 552 | return; | |
| 553 | } | |
| 554 | dispatch({ | |
| 555 | type: "SET_THREAD_TURN_CHECKPOINT_DIFFS", | |
| 556 | threadId: activeThreadId, | |
| 557 | checkpointDiffByTurnId, | |
| 558 | }); | |
| 559 | }, [activeThreadId, checkpointDiffHydrationQueries, checkpointDiffHydrationTargets, dispatch]); | |
| 560 | ||
| 561 | 561 | const completionSummary = useMemo(() => { |
| 562 | 562 | if (!activeThread?.latestTurnStartedAt) return null; |
| 563 | 563 | if (!activeThread.latestTurnCompletedAt) return null; |
apps/web/src/components/DiffPanel.tsx+58-119| ... | ... | @@ -1,7 +1,10 @@ |
| 1 | 1 | import { parsePatchFiles } from "@pierre/diffs"; |
| 2 | 2 | import { FileDiff, type FileDiffMetadata } from "@pierre/diffs/react"; |
| 3 | import { Columns2Icon, Rows3Icon, XIcon } from "lucide-react"; | |
| 3 | import { useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 4 | import { Columns2Icon, Rows3Icon } from "lucide-react"; | |
| 4 | 5 | import { useEffect, useMemo, useRef, useState } from "react"; |
| 6 | import { checkpointDiffQueryOptions, providerQueryKeys } from "~/lib/providerReactQuery"; | |
| 7 | import { cn } from "~/lib/utils"; | |
| 5 | 8 | import { isElectron } from "../env"; |
| 6 | 9 | import { useNativeApi } from "../hooks/useNativeApi"; |
| 7 | 10 | import { useTheme } from "../hooks/useTheme"; |
| ... | ... | @@ -11,9 +14,6 @@ import { |
| 11 | 14 | inferCheckpointTurnCountByTurnId, |
| 12 | 15 | } from "../session-logic"; |
| 13 | 16 | import { useStore } from "../store"; |
| 14 | import { Button } from "./ui/button"; | |
| 15 | import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; | |
| 16 | import { cn } from "~/lib/utils"; | |
| 17 | 17 | import { ToggleGroup, Toggle } from "./ui/toggle-group"; |
| 18 | 18 | |
| 19 | 19 | type DiffRenderMode = "stacked" | "split"; |
| ... | ... | @@ -63,14 +63,6 @@ function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { |
| 63 | 63 | return raw; |
| 64 | 64 | } |
| 65 | 65 | |
| 66 | function buildCheckpointDiffKey( | |
| 67 | sessionId: string, | |
| 68 | fromTurnCount: number, | |
| 69 | toTurnCount: number, | |
| 70 | ): string { | |
| 71 | return `${sessionId}:${fromTurnCount}-${toTurnCount}`; | |
| 72 | } | |
| 73 | ||
| 74 | 66 | interface DiffPanelProps { |
| 75 | 67 | mode?: "inline" | "sheet"; |
| 76 | 68 | } |
| ... | ... | @@ -79,12 +71,13 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 79 | 71 | const api = useNativeApi(); |
| 80 | 72 | const { resolvedTheme } = useTheme(); |
| 81 | 73 | const { state, dispatch } = useStore(); |
| 82 | const [checkpointDiffByKey, setCheckpointDiffByKey] = useState<Record<string, string>>({}); | |
| 83 | const [isLoadingCheckpointDiff, setIsLoadingCheckpointDiff] = useState(false); | |
| 84 | const [checkpointDiffError, setCheckpointDiffError] = useState<string | null>(null); | |
| 74 | const queryClient = useQueryClient(); | |
| 85 | 75 | const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked"); |
| 86 | 76 | const patchViewportRef = useRef<HTMLDivElement>(null); |
| 87 | 77 | const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId); |
| 78 | const activeThreadRuntimeId = | |
| 79 | activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; | |
| 80 | const activeSessionId = activeThread?.session?.sessionId ?? null; | |
| 88 | 81 | const turnDiffSummaries = useMemo( |
| 89 | 82 | () => |
| 90 | 83 | activeThread?.turnDiffSummaries.length |
| ... | ... | @@ -97,11 +90,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 97 | 90 | [turnDiffSummaries], |
| 98 | 91 | ); |
| 99 | 92 | |
| 100 | useEffect(() => { | |
| 101 | setCheckpointDiffByKey({}); | |
| 102 | setCheckpointDiffError(null); | |
| 103 | }, [activeThread?.id]); | |
| 104 | ||
| 105 | 93 | const canApplyStoredTarget = Boolean(activeThread && state.diffThreadId === activeThread.id); |
| 106 | 94 | const selectedTurnId = canApplyStoredTarget ? state.diffTurnId : null; |
| 107 | 95 | const selectedFilePath = |
| ... | ... | @@ -147,41 +135,57 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 147 | 135 | : null, |
| 148 | 136 | [conversationCheckpointTurnCount, selectedTurn], |
| 149 | 137 | ); |
| 150 | const selectedCheckpointDiffKey = | |
| 151 | activeThread?.session?.sessionId && selectedCheckpointRange | |
| 152 | ? buildCheckpointDiffKey( | |
| 153 | activeThread.session.sessionId, | |
| 154 | selectedCheckpointRange.fromTurnCount, | |
| 155 | selectedCheckpointRange.toTurnCount, | |
| 156 | ) | |
| 157 | : null; | |
| 158 | const conversationCheckpointDiffKey = | |
| 159 | activeThread?.session?.sessionId && conversationCheckpointRange | |
| 160 | ? buildCheckpointDiffKey( | |
| 161 | activeThread.session.sessionId, | |
| 162 | conversationCheckpointRange.fromTurnCount, | |
| 163 | conversationCheckpointRange.toTurnCount, | |
| 164 | ) | |
| 165 | : null; | |
| 166 | const selectedTurnCheckpointDiff = | |
| 167 | selectedCheckpointDiffKey === null ? undefined : checkpointDiffByKey[selectedCheckpointDiffKey]; | |
| 168 | const conversationCheckpointDiff = | |
| 169 | conversationCheckpointDiffKey === null | |
| 170 | ? undefined | |
| 171 | : checkpointDiffByKey[conversationCheckpointDiffKey]; | |
| 138 | const activeCheckpointRange = selectedTurn | |
| 139 | ? selectedCheckpointRange | |
| 140 | : conversationCheckpointRange; | |
| 141 | const activeCheckpointDiffQuery = useQuery( | |
| 142 | checkpointDiffQueryOptions(api, { | |
| 143 | sessionId: activeSessionId, | |
| 144 | threadRuntimeId: activeThreadRuntimeId, | |
| 145 | fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, | |
| 146 | toTurnCount: activeCheckpointRange?.toTurnCount ?? null, | |
| 147 | }), | |
| 148 | ); | |
| 149 | const selectedTurnCheckpointDiff = selectedTurn | |
| 150 | ? activeCheckpointDiffQuery.data?.diff | |
| 151 | : undefined; | |
| 152 | const conversationCheckpointDiff = selectedTurn | |
| 153 | ? undefined | |
| 154 | : activeCheckpointDiffQuery.data?.diff; | |
| 155 | const isLoadingCheckpointDiff = activeCheckpointDiffQuery.isPending; | |
| 156 | const checkpointDiffError = | |
| 157 | activeCheckpointDiffQuery.error instanceof Error | |
| 158 | ? activeCheckpointDiffQuery.error.message | |
| 159 | : activeCheckpointDiffQuery.error | |
| 160 | ? "Failed to load checkpoint diff." | |
| 161 | : null; | |
| 162 | ||
| 163 | useEffect(() => { | |
| 164 | if (!activeThread?.id || !selectedTurn || !selectedTurnCheckpointDiff) { | |
| 165 | return; | |
| 166 | } | |
| 167 | dispatch({ | |
| 168 | type: "SET_THREAD_TURN_CHECKPOINT_DIFFS", | |
| 169 | threadId: activeThread.id, | |
| 170 | checkpointDiffByTurnId: { | |
| 171 | [selectedTurn.turnId]: selectedTurnCheckpointDiff, | |
| 172 | }, | |
| 173 | }); | |
| 174 | }, [activeThread?.id, dispatch, selectedTurn, selectedTurnCheckpointDiff]); | |
| 175 | ||
| 172 | 176 | const selectedPatch = useMemo(() => { |
| 173 | 177 | const patchForSummary = (summary: (typeof turnDiffSummaries)[number]): string | undefined => { |
| 174 | 178 | const checkpointTurnCount = |
| 175 | 179 | summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; |
| 176 | if (activeThread?.session?.sessionId && typeof checkpointTurnCount === "number") { | |
| 177 | const checkpointPatch = | |
| 178 | checkpointDiffByKey[ | |
| 179 | buildCheckpointDiffKey( | |
| 180 | activeThread.session.sessionId, | |
| 181 | Math.max(0, checkpointTurnCount - 1), | |
| 182 | checkpointTurnCount, | |
| 183 | ) | |
| 184 | ]; | |
| 180 | if (activeSessionId && typeof checkpointTurnCount === "number") { | |
| 181 | const checkpointPatch = queryClient.getQueryData<{ diff: string }>( | |
| 182 | providerQueryKeys.checkpointDiff({ | |
| 183 | sessionId: activeSessionId, | |
| 184 | threadRuntimeId: activeThreadRuntimeId, | |
| 185 | fromTurnCount: Math.max(0, checkpointTurnCount - 1), | |
| 186 | toTurnCount: checkpointTurnCount, | |
| 187 | }), | |
| 188 | )?.diff; | |
| 185 | 189 | if (checkpointPatch) { |
| 186 | 190 | return checkpointPatch; |
| 187 | 191 | } |
| ... | ... | @@ -239,10 +243,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 239 | 243 | } |
| 240 | 244 | return patches.join("\n\n"); |
| 241 | 245 | }, [ |
| 242 | activeThread?.session?.sessionId, | |
| 243 | checkpointDiffByKey, | |
| 246 | activeSessionId, | |
| 247 | activeThreadRuntimeId, | |
| 244 | 248 | conversationCheckpointDiff, |
| 245 | 249 | inferredCheckpointTurnCountByTurnId, |
| 250 | queryClient, | |
| 246 | 251 | selectedTurn, |
| 247 | 252 | selectedTurnCheckpointDiff, |
| 248 | 253 | turnDiffSummaries, |
| ... | ... | @@ -260,72 +265,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 260 | 265 | ); |
| 261 | 266 | }, [renderablePatch]); |
| 262 | 267 | |
| 263 | useEffect(() => { | |
| 264 | const checkpointFetchRange = selectedTurn | |
| 265 | ? selectedCheckpointRange | |
| 266 | : conversationCheckpointRange; | |
| 267 | const checkpointFetchKey = selectedTurn | |
| 268 | ? selectedCheckpointDiffKey | |
| 269 | : conversationCheckpointDiffKey; | |
| 270 | if (!api || !activeThread?.session?.sessionId || !checkpointFetchRange || !checkpointFetchKey) { | |
| 271 | return; | |
| 272 | } | |
| 273 | if (checkpointDiffByKey[checkpointFetchKey] !== undefined) { | |
| 274 | return; | |
| 275 | } | |
| 276 | ||
| 277 | let cancelled = false; | |
| 278 | setIsLoadingCheckpointDiff(true); | |
| 279 | setCheckpointDiffError(null); | |
| 280 | void api.providers | |
| 281 | .getCheckpointDiff({ | |
| 282 | sessionId: activeThread.session.sessionId, | |
| 283 | fromTurnCount: checkpointFetchRange.fromTurnCount, | |
| 284 | toTurnCount: checkpointFetchRange.toTurnCount, | |
| 285 | }) | |
| 286 | .then((result) => { | |
| 287 | if (cancelled) return; | |
| 288 | setCheckpointDiffByKey((previous) => ({ | |
| 289 | ...previous, | |
| 290 | [checkpointFetchKey]: result.diff, | |
| 291 | })); | |
| 292 | if (selectedTurn) { | |
| 293 | dispatch({ | |
| 294 | type: "SET_THREAD_TURN_CHECKPOINT_DIFFS", | |
| 295 | threadId: activeThread.id, | |
| 296 | checkpointDiffByTurnId: { | |
| 297 | [selectedTurn.turnId]: result.diff, | |
| 298 | }, | |
| 299 | }); | |
| 300 | } | |
| 301 | }) | |
| 302 | .catch((error) => { | |
| 303 | if (cancelled) return; | |
| 304 | setCheckpointDiffError( | |
| 305 | error instanceof Error ? error.message : "Failed to load checkpoint diff.", | |
| 306 | ); | |
| 307 | }) | |
| 308 | .finally(() => { | |
| 309 | if (cancelled) return; | |
| 310 | setIsLoadingCheckpointDiff(false); | |
| 311 | }); | |
| 312 | ||
| 313 | return () => { | |
| 314 | cancelled = true; | |
| 315 | }; | |
| 316 | }, [ | |
| 317 | activeThread?.id, | |
| 318 | activeThread?.session?.sessionId, | |
| 319 | api, | |
| 320 | checkpointDiffByKey, | |
| 321 | conversationCheckpointDiffKey, | |
| 322 | conversationCheckpointRange, | |
| 323 | dispatch, | |
| 324 | selectedCheckpointDiffKey, | |
| 325 | selectedCheckpointRange, | |
| 326 | selectedTurn, | |
| 327 | ]); | |
| 328 | ||
| 329 | 268 | useEffect(() => { |
| 330 | 269 | if (!selectedFilePath || !patchViewportRef.current) { |
| 331 | 270 | return; |
apps/web/src/lib/providerReactQuery.ts created+52| ... | ... | @@ -0,0 +1,52 @@ |
| 1 | import type { NativeApi } from "@t3tools/contracts"; | |
| 2 | import { queryOptions } from "@tanstack/react-query"; | |
| 3 | ||
| 4 | export interface CheckpointDiffQueryInput { | |
| 5 | sessionId: string | null; | |
| 6 | threadRuntimeId: string | null; | |
| 7 | fromTurnCount: number | null; | |
| 8 | toTurnCount: number | null; | |
| 9 | } | |
| 10 | ||
| 11 | export const providerQueryKeys = { | |
| 12 | all: ["providers"] as const, | |
| 13 | checkpointDiff: (input: CheckpointDiffQueryInput) => | |
| 14 | [ | |
| 15 | "providers", | |
| 16 | "checkpointDiff", | |
| 17 | input.sessionId, | |
| 18 | input.threadRuntimeId, | |
| 19 | input.fromTurnCount, | |
| 20 | input.toTurnCount, | |
| 21 | ] as const, | |
| 22 | }; | |
| 23 | ||
| 24 | export function checkpointDiffQueryOptions( | |
| 25 | api: NativeApi | undefined, | |
| 26 | input: CheckpointDiffQueryInput, | |
| 27 | ) { | |
| 28 | const hasValidRange = | |
| 29 | typeof input.fromTurnCount === "number" && typeof input.toTurnCount === "number"; | |
| 30 | ||
| 31 | return queryOptions({ | |
| 32 | queryKey: providerQueryKeys.checkpointDiff(input), | |
| 33 | queryFn: async () => { | |
| 34 | if (!api || !input.sessionId || !hasValidRange) { | |
| 35 | throw new Error("Checkpoint diff is unavailable."); | |
| 36 | } | |
| 37 | const { fromTurnCount, toTurnCount } = input; | |
| 38 | if (typeof fromTurnCount !== "number" || typeof toTurnCount !== "number") { | |
| 39 | throw new Error("Checkpoint diff range is invalid."); | |
| 40 | } | |
| 41 | return api.providers.getCheckpointDiff({ | |
| 42 | sessionId: input.sessionId, | |
| 43 | fromTurnCount, | |
| 44 | toTurnCount, | |
| 45 | }); | |
| 46 | }, | |
| 47 | enabled: !!api && !!input.sessionId && hasValidRange, | |
| 48 | staleTime: Infinity, | |
| 49 | retry: 8, | |
| 50 | retryDelay: (attempt) => Math.min(2_000, 150 * 2 ** (attempt - 1)), | |
| 51 | }); | |
| 52 | } |
apps/web/src/store.test.ts+6-1| ... | ... | @@ -880,7 +880,12 @@ describe("store reducer thread continuity", () => { |
| 880 | 880 | { |
| 881 | 881 | turnId: "turn-1", |
| 882 | 882 | completedAt: "2026-02-09T00:00:03.000Z", |
| 883 | files: [{ path: "src/from-checkpoint.ts", diff: "diff --git a/src/from-checkpoint.ts b/src/from-checkpoint.ts" }], | |
| 883 | files: [ | |
| 884 | { | |
| 885 | path: "src/from-checkpoint.ts", | |
| 886 | diff: "diff --git a/src/from-checkpoint.ts b/src/from-checkpoint.ts", | |
| 887 | }, | |
| 888 | ], | |
| 884 | 889 | unifiedDiff: "diff --git a/src/from-checkpoint.ts b/src/from-checkpoint.ts", |
| 885 | 890 | checkpointDiffLoaded: true, |
| 886 | 891 | }, |
apps/web/src/store.ts+5-1| ... | ... | @@ -1075,7 +1075,11 @@ export function reducer(state: AppState, action: Action): AppState { |
| 1075 | 1075 | existing.deletions === file.deletions |
| 1076 | 1076 | ); |
| 1077 | 1077 | }); |
| 1078 | if (summary.unifiedDiff === checkpointDiff && filesUnchanged && summary.checkpointDiffLoaded) { | |
| 1078 | if ( | |
| 1079 | summary.unifiedDiff === checkpointDiff && | |
| 1080 | filesUnchanged && | |
| 1081 | summary.checkpointDiffLoaded | |
| 1082 | ) { | |
| 1079 | 1083 | return summary; |
| 1080 | 1084 | } |
| 1081 | 1085 |
packages/contracts/src/ipc.ts+3-1| ... | ... | @@ -95,7 +95,9 @@ export interface NativeApi { |
| 95 | 95 | respondToRequest: (input: ProviderRespondToRequestInput) => Promise<void>; |
| 96 | 96 | stopSession: (input: ProviderStopSessionInput) => Promise<void>; |
| 97 | 97 | listSessions: () => Promise<ProviderSession[]>; |
| 98 | listCheckpoints: (input: ProviderListCheckpointsInput) => Promise<ProviderListCheckpointsResult>; | |
| 98 | listCheckpoints: ( | |
| 99 | input: ProviderListCheckpointsInput, | |
| 100 | ) => Promise<ProviderListCheckpointsResult>; | |
| 99 | 101 | getCheckpointDiff: ( |
| 100 | 102 | input: ProviderGetCheckpointDiffInput, |
| 101 | 103 | ) => Promise<ProviderGetCheckpointDiffResult>; |