authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-18 10:33:00-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-18 21:59:17-08:00
logc90e245038970029f777200201048513da8f84e8
treeedfcdabcdd353f50e7946456467a37d8bd04859f
parent200e43e44c52e0f819f7162a6a3fb7551a9ee013

Address PR review feedback for checkpoint and diff flows

Co-authored-by: codex <codex@users.noreply.github.com>

16 files changed, 571 insertions(+), 148 deletions(-)

apps/server/src/codexAppServerManager.test.ts+29
......@@ -262,6 +262,35 @@ describe("thread checkpoint control", () => {
262262 });
263263 });
264264
265 it("reads thread turns from flat thread/read responses", async () => {
266 const { manager, context, sendRequest } = createThreadControlHarness();
267 sendRequest.mockResolvedValue({
268 threadId: "thread_1",
269 turns: [
270 {
271 id: "turn_1",
272 items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }],
273 },
274 ],
275 });
276
277 const result = await manager.readThread("sess_1");
278
279 expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", {
280 threadId: "thread_1",
281 includeTurns: true,
282 });
283 expect(result).toEqual({
284 threadId: "thread_1",
285 turns: [
286 {
287 id: "turn_1",
288 items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }],
289 },
290 ],
291 });
292 });
293
265294 it("rolls back turns via thread/rollback and resets session running state", async () => {
266295 const { manager, context, sendRequest, updateSession } = createThreadControlHarness();
267296 sendRequest.mockResolvedValue({
apps/server/src/codexAppServerManager.ts+1-1
......@@ -796,7 +796,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
796796 throw new Error(`${method} response did not include a thread id.`);
797797 }
798798
799 const turnsRaw = this.readArray(thread, "turns") ?? [];
799 const turnsRaw = this.readArray(thread, "turns") ?? this.readArray(responseRecord, "turns") ?? [];
800800 const turns = turnsRaw.map((turnValue, index) => {
801801 const turn = this.readObject(turnValue);
802802 const turnId = this.readString(turn, "id") ?? `${threadId}:turn:${index + 1}`;
apps/server/src/filesystemCheckpointStore.ts+30-16
......@@ -1,4 +1,4 @@
1import { mkdtemp, rm, writeFile } from "node:fs/promises";
1import { mkdtemp, rm } from "node:fs/promises";
22import os from "node:os";
33import path from "node:path";
44import { randomUUID } from "node:crypto";
......@@ -47,18 +47,17 @@ export class FilesystemCheckpointStore {
4747 const ref = checkpointRefForThreadTurn(threadId, turnCount);
4848
4949 const tempDir = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoint-"));
50 const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`);
51 const commitEnv: NodeJS.ProcessEnv = {
52 ...process.env,
53 GIT_INDEX_FILE: tempIndexPath,
54 GIT_AUTHOR_NAME: "T3 Code",
55 GIT_AUTHOR_EMAIL: "codex@users.noreply.github.com",
56 GIT_COMMITTER_NAME: "T3 Code",
57 GIT_COMMITTER_EMAIL: "codex@users.noreply.github.com",
58 };
5950
6051 try {
61 await writeFile(tempIndexPath, "");
52 const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`);
53 const commitEnv: NodeJS.ProcessEnv = {
54 ...process.env,
55 GIT_INDEX_FILE: tempIndexPath,
56 GIT_AUTHOR_NAME: "T3 Code",
57 GIT_AUTHOR_EMAIL: "codex@users.noreply.github.com",
58 GIT_COMMITTER_NAME: "T3 Code",
59 GIT_COMMITTER_EMAIL: "codex@users.noreply.github.com",
60 };
6261
6362 const hasHead = await this.hasHeadCommit(cwd);
6463 if (hasHead) {
......@@ -133,7 +132,7 @@ export class FilesystemCheckpointStore {
133132
134133 await this.runGit(cwd, ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."]);
135134 await this.runGit(cwd, ["clean", "-fd", "--", "."]);
136 await this.runGit(cwd, ["reset", "--quiet", "--", "."], { allowNonZeroExit: true });
135 await this.runGit(cwd, ["reset", "--quiet", "--", "."]);
137136 return true;
138137 }
139138
......@@ -228,11 +227,20 @@ export class FilesystemCheckpointStore {
228227 return turn !== null && turn > maxTurnCount;
229228 });
230229
231 await Promise.all(
232 refsToDelete.map((refName) =>
233 this.runGit(cwd, ["update-ref", "-d", refName], { allowNonZeroExit: true }),
234 ),
230 const batchSize = 32;
231 const batches = Array.from(
232 { length: Math.ceil(refsToDelete.length / batchSize) },
233 (_, index) => refsToDelete.slice(index * batchSize, (index + 1) * batchSize),
235234 );
235 await batches.reduce<Promise<void>>((pending, batch) => {
236 return pending.then(async () => {
237 await Promise.all(
238 batch.map((refName) =>
239 this.runGit(cwd, ["update-ref", "-d", refName], { allowNonZeroExit: true }),
240 ),
241 );
242 });
243 }, Promise.resolve());
236244 }
237245
238246 private async hasHeadCommit(cwd: string): Promise<boolean> {
......@@ -288,6 +296,12 @@ export class FilesystemCheckpointStore {
288296 throw new Error(`${quoteGitCommand(args)} timed out.`);
289297 }
290298
299 if (result.stdoutTruncated || result.stderrTruncated) {
300 throw new Error(
301 `${quoteGitCommand(args)} output exceeded ${DEFAULT_MAX_OUTPUT_BYTES} bytes and was truncated.`,
302 );
303 }
304
291305 if (!options.allowNonZeroExit && result.code !== 0) {
292306 const stderr = result.stderr.trim();
293307 throw new Error(
apps/server/src/providerManager.test.ts+167-52
......@@ -151,55 +151,44 @@ describe("ProviderManager", () => {
151151
152152 it("rolls back from a selected checkpoint turn count", async () => {
153153 const manager = new ProviderManager();
154 const codex = (
155 manager as unknown as {
156 codex: {
157 hasSession: (sessionId: string) => boolean;
158 readThread: (sessionId: string) => Promise<{
159 threadId: string;
160 turns: Array<{ id: string; items: unknown[] }>;
161 }>;
162 rollbackThread: (
163 sessionId: string,
164 numTurns: number,
165 ) => Promise<{
166 threadId: string;
167 turns: Array<{ id: string; items: unknown[] }>;
168 }>;
169 listSessions: () => Array<{
170 sessionId: string;
171 provider: "codex";
172 status: "ready";
173 createdAt: string;
174 updatedAt: string;
175 cwd?: string;
176 }>;
177 };
178 filesystemCheckpointStore: {
179 isGitRepository: (cwd: string) => Promise<boolean>;
180 ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise<boolean>;
181 };
182 }
183 ).codex;
184154 const internals = manager as unknown as {
155 codex: {
156 hasSession: (sessionId: string) => boolean;
157 readThread: (sessionId: string) => Promise<{
158 threadId: string;
159 turns: Array<{ id: string; items: unknown[] }>;
160 }>;
161 rollbackThread: (
162 sessionId: string,
163 numTurns: number,
164 ) => Promise<{
165 threadId: string;
166 turns: Array<{ id: string; items: unknown[] }>;
167 }>;
168 };
185169 filesystemCheckpointStore: {
186 isGitRepository: (cwd: string) => Promise<boolean>;
187 ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise<boolean>;
170 hasCheckpoint: (input: {
171 cwd: string;
172 threadId: string;
173 turnCount: number;
174 }) => Promise<boolean>;
175 restoreCheckpoint: (input: {
176 cwd: string;
177 threadId: string;
178 turnCount: number;
179 }) => Promise<boolean>;
180 pruneAfterTurn: (input: {
181 cwd: string;
182 threadId: string;
183 maxTurnCount: number;
184 }) => Promise<void>;
188185 };
186 sessionCheckpointCwds: Map<string, string>;
189187 };
190188
191189 const rollbackCalls: Array<{ sessionId: string; numTurns: number }> = [];
192 codex.hasSession = () => true;
193 codex.listSessions = () => [
194 {
195 sessionId: "sess_1",
196 provider: "codex",
197 status: "ready",
198 createdAt: "2026-02-18T00:00:00.000Z",
199 updatedAt: "2026-02-18T00:00:00.000Z",
200 },
201 ];
202 codex.readThread = async () => ({
190 internals.codex.hasSession = () => true;
191 internals.codex.readThread = async () => ({
203192 threadId: "thr_1",
204193 turns: [
205194 { id: "turn_1", items: [] },
......@@ -207,15 +196,20 @@ describe("ProviderManager", () => {
207196 { id: "turn_3", items: [] },
208197 ],
209198 });
210 codex.rollbackThread = async (sessionId, numTurns) => {
199 internals.codex.rollbackThread = async (sessionId, numTurns) => {
211200 rollbackCalls.push({ sessionId, numTurns });
212201 return {
213202 threadId: "thr_1",
214203 turns: [{ id: "turn_1", items: [] }],
215204 };
216205 };
217 internals.filesystemCheckpointStore.isGitRepository = async () => false;
218 internals.filesystemCheckpointStore.ensureRootCheckpoint = async () => true;
206 const hasCheckpoint = vi.fn(async () => true);
207 const restoreCheckpoint = vi.fn(async () => true);
208 const pruneAfterTurn = vi.fn(async () => undefined);
209 internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint;
210 internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint;
211 internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn;
212 internals.sessionCheckpointCwds.set("sess_1", "/repo");
219213
220214 const result = await manager.revertToCheckpoint({
221215 sessionId: "sess_1",
......@@ -228,6 +222,21 @@ describe("ProviderManager", () => {
228222 expect(result.messageCount).toBe(0);
229223 expect(result.rolledBackTurns).toBe(2);
230224 expect(result.checkpoints).toHaveLength(2);
225 expect(hasCheckpoint).toHaveBeenCalledWith({
226 cwd: "/repo",
227 threadId: "thr_1",
228 turnCount: 1,
229 });
230 expect(restoreCheckpoint).toHaveBeenCalledWith({
231 cwd: "/repo",
232 threadId: "thr_1",
233 turnCount: 1,
234 });
235 expect(pruneAfterTurn).toHaveBeenCalledWith({
236 cwd: "/repo",
237 threadId: "thr_1",
238 maxTurnCount: 1,
239 });
231240
232241 manager.dispose();
233242 });
......@@ -471,6 +480,101 @@ describe("ProviderManager", () => {
471480 }
472481 });
473482
483 it("does not fall back to process cwd when a session cwd exists but is not a git repo", async () => {
484 const manager = new ProviderManager();
485 const internals = manager as unknown as {
486 codex: {
487 hasSession: (sessionId: string) => boolean;
488 listSessions: () => Array<{
489 sessionId: string;
490 provider: "codex";
491 status: "ready";
492 createdAt: string;
493 updatedAt: string;
494 cwd?: string;
495 }>;
496 };
497 filesystemCheckpointStore: {
498 isGitRepository: (cwd: string) => Promise<boolean>;
499 };
500 sessionCheckpointCwds: Map<string, string>;
501 };
502
503 const processCwd = vi.spyOn(process, "cwd").mockReturnValue("/fallback-repo");
504 try {
505 internals.codex.hasSession = () => true;
506 internals.codex.listSessions = () => [
507 {
508 sessionId: "sess_1",
509 provider: "codex",
510 status: "ready",
511 createdAt: "2026-02-18T00:00:00.000Z",
512 updatedAt: "2026-02-18T00:00:00.000Z",
513 cwd: "/session-cwd",
514 },
515 ];
516 const isGitRepository = vi.fn(async (cwd: string) => cwd === "/fallback-repo");
517 internals.filesystemCheckpointStore.isGitRepository = isGitRepository;
518 internals.sessionCheckpointCwds.delete("sess_1");
519
520 await expect(
521 manager.getCheckpointDiff({
522 sessionId: "sess_1",
523 fromTurnCount: 0,
524 toTurnCount: 1,
525 }),
526 ).rejects.toThrow("Filesystem checkpoints are unavailable for this session.");
527 expect(isGitRepository).toHaveBeenCalledWith("/session-cwd");
528 expect(isGitRepository).not.toHaveBeenCalledWith("/fallback-repo");
529 } finally {
530 processCwd.mockRestore();
531 manager.dispose();
532 }
533 });
534
535 it("fails revert when filesystem checkpoints are unavailable for the session", async () => {
536 const manager = new ProviderManager();
537 const internals = manager as unknown as {
538 codex: {
539 hasSession: (sessionId: string) => boolean;
540 listSessions: () => Array<{
541 sessionId: string;
542 provider: "codex";
543 status: "ready";
544 createdAt: string;
545 updatedAt: string;
546 cwd?: string;
547 }>;
548 };
549 filesystemCheckpointStore: {
550 isGitRepository: (cwd: string) => Promise<boolean>;
551 };
552 sessionCheckpointCwds: Map<string, string>;
553 };
554
555 internals.codex.hasSession = () => true;
556 internals.codex.listSessions = () => [
557 {
558 sessionId: "sess_1",
559 provider: "codex",
560 status: "ready",
561 createdAt: "2026-02-18T00:00:00.000Z",
562 updatedAt: "2026-02-18T00:00:00.000Z",
563 },
564 ];
565 internals.filesystemCheckpointStore.isGitRepository = async () => false;
566 internals.sessionCheckpointCwds.delete("sess_1");
567
568 await expect(
569 manager.revertToCheckpoint({
570 sessionId: "sess_1",
571 turnCount: 1,
572 }),
573 ).rejects.toThrow("Filesystem checkpoints are unavailable for this session.");
574
575 manager.dispose();
576 });
577
474578 it("restores filesystem checkpoint when reverting and checkpointing is enabled", async () => {
475579 const manager = new ProviderManager();
476580 const internals = manager as unknown as {
......@@ -509,6 +613,7 @@ describe("ProviderManager", () => {
509613 };
510614
511615 internals.codex.hasSession = () => true;
616 const callOrder: string[] = [];
512617 internals.codex.readThread = async () => ({
513618 threadId: "thr_1",
514619 turns: [
......@@ -516,13 +621,22 @@ describe("ProviderManager", () => {
516621 { id: "turn_2", items: [] },
517622 ],
518623 });
519 internals.codex.rollbackThread = async () => ({
520 threadId: "thr_1",
521 turns: [{ id: "turn_1", items: [] }],
522 });
624 internals.codex.rollbackThread = async () => {
625 callOrder.push("rollback");
626 return {
627 threadId: "thr_1",
628 turns: [{ id: "turn_1", items: [] }],
629 };
630 };
523631 const hasCheckpoint = vi.fn(async () => true);
524 const restoreCheckpoint = vi.fn(async () => true);
525 const pruneAfterTurn = vi.fn(async () => undefined);
632 const restoreCheckpoint = vi.fn(async () => {
633 callOrder.push("restore");
634 return true;
635 });
636 const pruneAfterTurn = vi.fn(async () => {
637 callOrder.push("prune");
638 return undefined;
639 });
526640 internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint;
527641 internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint;
528642 internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn;
......@@ -549,6 +663,7 @@ describe("ProviderManager", () => {
549663 threadId: "thr_1",
550664 maxTurnCount: 1,
551665 });
666 expect(callOrder).toEqual(["restore", "rollback", "prune"]);
552667
553668 manager.dispose();
554669 });
apps/server/src/providerManager.ts+26-22
......@@ -308,6 +308,9 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
308308 }
309309
310310 const checkpointCwd = await this.getOrInitializeFilesystemCheckpointCwd(input.sessionId);
311 if (!checkpointCwd) {
312 throw new Error("Filesystem checkpoints are unavailable for this session.");
313 }
311314 return this.withFilesystemLock(input.sessionId, async () => {
312315 const beforeSnapshot = await this.codex.readThread(input.sessionId);
313316 const currentTurnCount = beforeSnapshot.turns.length;
......@@ -330,29 +333,28 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
330333 }
331334 }
332335
336 const restored = await this.filesystemCheckpointStore.restoreCheckpoint({
337 cwd: checkpointCwd,
338 threadId: beforeSnapshot.threadId,
339 turnCount: input.turnCount,
340 });
341 if (!restored) {
342 throw new Error(
343 `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${beforeSnapshot.threadId}.`,
344 );
345 }
346
333347 const requestedRollbackTurns = currentTurnCount - input.turnCount;
334348 const afterSnapshot =
335349 requestedRollbackTurns > 0
336350 ? await this.codex.rollbackThread(input.sessionId, requestedRollbackTurns)
337351 : beforeSnapshot;
338352
339 if (checkpointCwd) {
340 const restored = await this.filesystemCheckpointStore.restoreCheckpoint({
341 cwd: checkpointCwd,
342 threadId: beforeSnapshot.threadId,
343 turnCount: input.turnCount,
344 });
345 if (!restored) {
346 throw new Error(
347 `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${beforeSnapshot.threadId}.`,
348 );
349 }
350 await this.filesystemCheckpointStore.pruneAfterTurn({
351 cwd: checkpointCwd,
352 threadId: afterSnapshot.threadId,
353 maxTurnCount: afterSnapshot.turns.length,
354 });
355 }
353 await this.filesystemCheckpointStore.pruneAfterTurn({
354 cwd: checkpointCwd,
355 threadId: afterSnapshot.threadId,
356 maxTurnCount: afterSnapshot.turns.length,
357 });
356358
357359 const checkpoints = buildCheckpoints(afterSnapshot.turns);
358360 const currentCheckpoint =
......@@ -415,7 +417,9 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
415417 threadId: snapshot.threadId,
416418 turnCount: snapshot.turns.length,
417419 });
418 this.sessionCheckpointCwds.set(session.sessionId, cwd);
420 if (this.codex.hasSession(session.sessionId)) {
421 this.sessionCheckpointCwds.set(session.sessionId, cwd);
422 }
419423 });
420424 }
421425
......@@ -428,9 +432,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
428432 const session = this.codex
429433 .listSessions()
430434 .find((candidate) => candidate.sessionId === sessionId);
431 const candidateCwds = Array.from(
432 new Set([session?.cwd, process.cwd()].filter((value): value is string => Boolean(value))),
433 );
435 const candidateCwds = session?.cwd ? [session.cwd] : [process.cwd()];
434436 if (candidateCwds.length === 0) {
435437 return null;
436438 }
......@@ -459,7 +461,9 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
459461 threadId: snapshot.threadId,
460462 turnCount: snapshot.turns.length,
461463 });
462 this.sessionCheckpointCwds.set(sessionId, supportedCwd);
464 if (this.codex.hasSession(sessionId)) {
465 this.sessionCheckpointCwds.set(sessionId, supportedCwd);
466 }
463467 return;
464468 }
465469
apps/web/src/components/ChatView.tsx+15-19
......@@ -53,9 +53,7 @@ import {
5353 countDiffStat,
5454 derivePendingApprovals,
5555 derivePhase,
56 deriveTurnDiffSummaries,
5756 deriveTimelineEntries,
58 inferCheckpointTurnCountByTurnId,
5957 type TurnDiffSummary,
6058 type PendingApproval,
6159 deriveWorkLogEntries,
......@@ -72,6 +70,7 @@ import {
7270} from "../types";
7371import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";
7472import { useTheme } from "../hooks/useTheme";
73import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
7574import BranchToolbar from "./BranchToolbar";
7675import GitActionsControl from "./GitActionsControl";
7776import {
......@@ -414,13 +413,8 @@ export default function ChatView() {
414413 () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries),
415414 [activeThread?.messages, workLogEntries],
416415 );
417 const turnDiffSummaries = useMemo(
418 () =>
419 activeThread?.turnDiffSummaries.length
420 ? activeThread.turnDiffSummaries
421 : deriveTurnDiffSummaries(activeThread?.events ?? []),
422 [activeThread?.events, activeThread?.turnDiffSummaries],
423 );
416 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
417 useTurnDiffSummaries(activeThread);
424418 const turnDiffSummaryByAssistantMessageId = useMemo(() => {
425419 const byMessageId = new Map<string, TurnDiffSummary>();
426420 for (const summary of turnDiffSummaries) {
......@@ -429,11 +423,6 @@ export default function ChatView() {
429423 }
430424 return byMessageId;
431425 }, [turnDiffSummaries]);
432 const inferredCheckpointTurnCountByTurnId = useMemo(
433 () => inferCheckpointTurnCountByTurnId(turnDiffSummaries),
434 [turnDiffSummaries],
435 );
436
437426 const revertTurnCountByUserMessageId = useMemo(() => {
438427 const byUserMessageId = new Map<string, number>();
439428 for (let index = 0; index < timelineEntries.length; index += 1) {
......@@ -1487,8 +1476,10 @@ export default function ChatView() {
14871476 const selectedDiffTurnId =
14881477 state.diffOpen && state.diffThreadId === activeThreadId ? state.diffTurnId : null;
14891478 const selectedDiffTurn =
1490 turnDiffSummaries.find((summary) => summary.turnId === selectedDiffTurnId) ??
1491 turnDiffSummaries[0];
1479 selectedDiffTurnId === null
1480 ? undefined
1481 : (turnDiffSummaries.find((summary) => summary.turnId === selectedDiffTurnId) ??
1482 turnDiffSummaries[0]);
14921483 const selectedTurnMissingPatchBody = Boolean(
14931484 selectedDiffTurn &&
14941485 !selectedDiffTurn.unifiedDiff &&
......@@ -1505,9 +1496,14 @@ export default function ChatView() {
15051496 }
15061497 checkpointHydrationSessionRequestRef.current.add(requestKey);
15071498 setThreadError(activeThreadId, null);
1508 void ensureSession().finally(() => {
1509 checkpointHydrationSessionRequestRef.current.delete(requestKey);
1510 });
1499 void ensureSession().then(
1500 () => {
1501 checkpointHydrationSessionRequestRef.current.delete(requestKey);
1502 },
1503 () => {
1504 // Keep the key on failure so this effect does not re-enter in a tight loop.
1505 },
1506 );
15111507 }, [
15121508 activeThreadId,
15131509 activeThreadRuntimeId,
apps/web/src/components/DiffPanel.tsx+10-17
......@@ -8,11 +8,8 @@ import { cn } from "~/lib/utils";
88import { isElectron } from "../env";
99import { useNativeApi } from "../hooks/useNativeApi";
1010import { useTheme } from "../hooks/useTheme";
11import {
12 deriveTurnDiffSummaries,
13 formatTimestamp,
14 inferCheckpointTurnCountByTurnId,
15} from "../session-logic";
11import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
12import { formatTimestamp } from "../session-logic";
1613import { useStore } from "../store";
1714import { ToggleGroup, Toggle } from "./ui/toggle-group";
1815
......@@ -78,17 +75,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
7875 const activeThreadRuntimeId =
7976 activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null;
8077 const activeSessionId = activeThread?.session?.sessionId ?? null;
81 const turnDiffSummaries = useMemo(
82 () =>
83 activeThread?.turnDiffSummaries.length
84 ? activeThread.turnDiffSummaries
85 : deriveTurnDiffSummaries(activeThread?.events ?? []),
86 [activeThread?.events, activeThread?.turnDiffSummaries],
87 );
88 const inferredCheckpointTurnCountByTurnId = useMemo(
89 () => inferCheckpointTurnCountByTurnId(turnDiffSummaries),
90 [turnDiffSummaries],
91 );
78 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
79 useTurnDiffSummaries(activeThread);
9280
9381 const canApplyStoredTarget = Boolean(activeThread && state.diffThreadId === activeThread.id);
9482 const selectedTurnId = canApplyStoredTarget ? state.diffTurnId : null;
......@@ -358,7 +346,12 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
358346 variant="outline"
359347 size="xs"
360348 value={[diffRenderMode]}
361 onValueChange={(value) => setDiffRenderMode(value[0] as DiffRenderMode)}
349 onValueChange={(value) => {
350 const next = value[0];
351 if (next === "stacked" || next === "split") {
352 setDiffRenderMode(next);
353 }
354 }}
362355 >
363356 <Toggle aria-label="Stacked diff view" value="stacked">
364357 <Rows3Icon className="size-3" />
apps/web/src/components/ui/toggle-group.tsx+2-2
......@@ -58,8 +58,8 @@ function Toggle({
5858}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
5959 const context = React.useContext(ToggleGroupContext);
6060
61 const resolvedVariant = context.variant || variant;
62 const resolvedSize = context.size || size;
61 const resolvedVariant = variant ?? context.variant;
62 const resolvedSize = size ?? context.size;
6363
6464 return (
6565 <ToggleComponent
apps/web/src/hooks/useTurnDiffSummaries.ts created+35
......@@ -0,0 +1,35 @@
1import type { ProviderEvent } from "@t3tools/contracts";
2import { useMemo } from "react";
3import { deriveTurnDiffSummaries, inferCheckpointTurnCountByTurnId } from "../session-logic";
4import type { Thread, TurnDiffSummary } from "../types";
5
6const derivedTurnDiffSummaryCache = new WeakMap<readonly ProviderEvent[], TurnDiffSummary[]>();
7
8function deriveTurnDiffSummariesCached(events: ProviderEvent[]): TurnDiffSummary[] {
9 const cached = derivedTurnDiffSummaryCache.get(events);
10 if (cached) {
11 return cached;
12 }
13 const derived = deriveTurnDiffSummaries(events);
14 derivedTurnDiffSummaryCache.set(events, derived);
15 return derived;
16}
17
18export function useTurnDiffSummaries(activeThread: Thread | undefined) {
19 const turnDiffSummaries = useMemo(() => {
20 if (!activeThread) {
21 return [];
22 }
23 if (activeThread.turnDiffSummaries.length > 0) {
24 return activeThread.turnDiffSummaries;
25 }
26 return deriveTurnDiffSummariesCached(activeThread.events);
27 }, [activeThread]);
28
29 const inferredCheckpointTurnCountByTurnId = useMemo(
30 () => inferCheckpointTurnCountByTurnId(turnDiffSummaries),
31 [turnDiffSummaries],
32 );
33
34 return { turnDiffSummaries, inferredCheckpointTurnCountByTurnId };
35}
apps/web/src/lib/providerReactQuery.ts+7-1
......@@ -28,7 +28,13 @@ export function checkpointDiffQueryOptions(
2828 input: CheckpointDiffQueryInput,
2929) {
3030 const hasValidRange =
31 typeof input.fromTurnCount === "number" && typeof input.toTurnCount === "number";
31 typeof input.fromTurnCount === "number" &&
32 typeof input.toTurnCount === "number" &&
33 Number.isInteger(input.fromTurnCount) &&
34 Number.isInteger(input.toTurnCount) &&
35 input.fromTurnCount >= 0 &&
36 input.toTurnCount >= 0 &&
37 input.fromTurnCount <= input.toTurnCount;
3238
3339 return queryOptions({
3440 queryKey: providerQueryKeys.checkpointDiff(input),
apps/web/src/persistenceSchema.test.ts+40
......@@ -325,6 +325,46 @@ describe("hydratePersistedState", () => {
325325 },
326326 ]);
327327 });
328
329 it("drops malformed persisted turn diff summaries instead of rejecting the whole snapshot", () => {
330 const payload = JSON.stringify({
331 version: 7,
332 runtimeMode: "full-access",
333 projects: [
334 {
335 id: "p-1",
336 name: "Project",
337 cwd: "/tmp/project",
338 model: "gpt-5.3-codex",
339 expanded: true,
340 },
341 ],
342 threads: [
343 {
344 id: "t-1",
345 codexThreadId: "thr_1",
346 projectId: "p-1",
347 title: "Thread",
348 model: "gpt-5.3-codex",
349 messages: [],
350 createdAt: "2026-02-08T10:00:00.000Z",
351 turnDiffSummaries: [
352 {
353 turnId: "turn-1",
354 completedAt: "2026-02-08T10:05:00.000Z",
355 files: [{ path: 123 }],
356 },
357 ],
358 },
359 ],
360 activeThreadId: "t-1",
361 });
362
363 const hydrated = hydratePersistedState(payload, false);
364 expect(hydrated).not.toBeNull();
365 expect(hydrated?.threads[0]?.id).toBe("t-1");
366 expect(hydrated?.threads[0]?.turnDiffSummaries).toEqual([]);
367 });
328368});
329369
330370describe("toPersistedState", () => {
apps/web/src/persistenceSchema.ts+1-1
......@@ -84,7 +84,7 @@ const persistedThreadSchema = z.object({
8484 lastVisitedAt: z.string().min(1).optional(),
8585 branch: z.string().min(1).nullable().optional(),
8686 worktreePath: z.string().min(1).nullable().optional(),
87 turnDiffSummaries: z.array(persistedTurnDiffSummarySchema).default([]),
87 turnDiffSummaries: z.array(persistedTurnDiffSummarySchema).catch([]).default([]),
8888});
8989
9090const persistedStateBodySchema = z.object({
apps/web/src/session-logic.test.ts+70-1
......@@ -493,7 +493,11 @@ describe("deriveTurnDiffSummaries", () => {
493493 expect(summaries).toHaveLength(1);
494494 expect(summaries[0]?.turnId).toBe("turn-1");
495495 expect(summaries[0]?.assistantMessageId).toBe("msg-1");
496 expect(summaries[0]?.files).toEqual([]);
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"]);
498 expect(summaries[0]?.files[0]?.kind).toBe("modified");
499 expect(summaries[0]?.files[0]?.additions).toBe(1);
500 expect(summaries[0]?.files[0]?.deletions).toBe(1);
497501 });
498502
499503 it("includes completed turns even when no fileChange tool events were emitted", () => {
......@@ -522,6 +526,55 @@ describe("deriveTurnDiffSummaries", () => {
522526 expect(summaries[0]?.turnId).toBe("turn-1");
523527 expect(summaries[0]?.files).toEqual([]);
524528 });
529
530 it("keeps newest diff data when older events provide stale values", () => {
531 const summaries = deriveTurnDiffSummaries([
532 makeEvent({
533 id: "evt-turn-diff-old",
534 method: "turn/diff/updated",
535 turnId: "turn-1",
536 createdAt: "2026-02-08T10:00:01.000Z",
537 payload: {
538 diff: [
539 "diff --git a/src/example.ts b/src/example.ts",
540 "@@ -1 +1 @@",
541 "-old",
542 "+older",
543 ].join("\n"),
544 },
545 }),
546 makeEvent({
547 id: "evt-turn-diff-new",
548 method: "turn/diff/updated",
549 turnId: "turn-1",
550 createdAt: "2026-02-08T10:00:02.000Z",
551 payload: {
552 diff: [
553 "diff --git a/src/example.ts b/src/example.ts",
554 "@@ -1 +1 @@",
555 "-old",
556 "+newest",
557 ].join("\n"),
558 },
559 }),
560 makeEvent({
561 id: "evt-turn-completed",
562 method: "turn/completed",
563 turnId: "turn-1",
564 createdAt: "2026-02-08T10:00:03.000Z",
565 payload: {
566 turn: {
567 id: "turn-1",
568 status: "completed",
569 },
570 },
571 }),
572 ]);
573
574 expect(summaries).toHaveLength(1);
575 expect(summaries[0]?.unifiedDiff).toContain("+newest");
576 expect(summaries[0]?.unifiedDiff).not.toContain("+older");
577 });
525578});
526579
527580describe("deriveTurnDiffFilesFromUnifiedDiff", () => {
......@@ -545,6 +598,22 @@ describe("deriveTurnDiffFilesFromUnifiedDiff", () => {
545598 expect(files[0]?.additions).toBe(1);
546599 expect(files[0]?.deletions).toBe(1);
547600 });
601
602 it("parses git headers when file paths include ` b/`", () => {
603 const files = deriveTurnDiffFilesFromUnifiedDiff(
604 [
605 "diff --git a/subdir b/example.ts b/subdir b/example.ts",
606 "--- a/subdir b/example.ts",
607 "+++ b/subdir b/example.ts",
608 "@@ -1 +1 @@",
609 "-old",
610 "+new",
611 ].join("\n"),
612 );
613
614 expect(files).toHaveLength(1);
615 expect(files[0]?.path).toBe("subdir b/example.ts");
616 });
548617});
549618
550619describe("derivePendingApprovals", () => {
apps/web/src/session-logic.ts+111-10
......@@ -33,10 +33,18 @@ export function asObject(value: unknown): Record<string, unknown> | undefined {
3333 return value as Record<string, unknown>;
3434}
3535
36function asArray(value: unknown): unknown[] | undefined {
37 return Array.isArray(value) ? value : undefined;
38}
39
3640export function asString(value: unknown): string | undefined {
3741 return typeof value === "string" ? value : undefined;
3842}
3943
44function asNumber(value: unknown): number | undefined {
45 return typeof value === "number" && Number.isFinite(value) ? value : undefined;
46}
47
4048export function formatTimestamp(isoDate: string): string {
4149 return new Intl.DateTimeFormat(undefined, {
4250 hour: "numeric",
......@@ -445,14 +453,77 @@ interface MutableTurnDiffSummary {
445453 completedAt: string | undefined;
446454 status: string | undefined;
447455 assistantMessageId: string | undefined;
456 unifiedDiff: string | undefined;
457 filesByPath: Map<string, TurnDiffFileChange>;
458}
459
460function parseFileChangeEntriesFromEvent(event: ProviderEvent): TurnDiffFileChange[] {
461 if (event.method !== "item/completed") {
462 return [];
463 }
464
465 const item = asObject(asObject(event.payload)?.item);
466 const itemType = normalizeItemType(asString(item?.type));
467 if (itemType !== "file change") {
468 return [];
469 }
470
471 const changes = asArray(item?.changes) ?? [];
472 const parsed: TurnDiffFileChange[] = [];
473 for (const rawChange of changes) {
474 const change = asObject(rawChange);
475 if (!change) continue;
476
477 const diff = normalizeDetail(asString(change.diff));
478 const path = normalizeDetail(asString(change.path)) ?? (diff ? parsePathFromDiff(diff) : undefined);
479 if (!path) continue;
480
481 const additions = asNumber(change.additions);
482 const deletions = asNumber(change.deletions);
483 const stat = additions !== undefined && deletions !== undefined ? null : diff ? countDiffStat(diff) : null;
484 const kind = normalizeDetail(asString(change.kind));
485 parsed.push({
486 path,
487 ...(kind ? { kind } : {}),
488 ...(diff ? { diff } : {}),
489 ...(additions !== undefined ? { additions } : stat ? { additions: stat.additions } : {}),
490 ...(deletions !== undefined ? { deletions } : stat ? { deletions: stat.deletions } : {}),
491 });
492 }
493
494 return parsed;
495}
496
497function mergeTurnDiffFileChange(
498 byPath: Map<string, TurnDiffFileChange>,
499 incoming: TurnDiffFileChange,
500): void {
501 const existing = byPath.get(incoming.path);
502 if (!existing) {
503 byPath.set(incoming.path, incoming);
504 return;
505 }
506
507 if (existing.kind === undefined && incoming.kind !== undefined) {
508 existing.kind = incoming.kind;
509 }
510 if (existing.diff === undefined && incoming.diff !== undefined) {
511 existing.diff = incoming.diff;
512 }
513 if (existing.additions === undefined && incoming.additions !== undefined) {
514 existing.additions = incoming.additions;
515 }
516 if (existing.deletions === undefined && incoming.deletions !== undefined) {
517 existing.deletions = incoming.deletions;
518 }
448519}
449520
450521function parsePathFromDiff(diff: string): string | undefined {
451522 const normalized = diff.replace(/\r\n/g, "\n");
452 const gitHeader = normalized.match(/^diff --git a\/(.+) b\/(.+)$/m);
453 if (gitHeader && gitHeader[2]) return gitHeader[2];
454523 const bPath = normalized.match(/^\+\+\+ b\/(.+)$/m);
455524 if (bPath && bPath[1]) return bPath[1];
525 const gitHeader = normalized.match(/^diff --git a\/(.+) b\/(.+)$/m);
526 if (gitHeader && gitHeader[2]) return gitHeader[2];
456527 const direct = normalized.match(/^\+\+\+ (.+)$/m);
457528 if (!direct || !direct[1] || direct[1] === "/dev/null") {
458529 return undefined;
......@@ -463,7 +534,7 @@ function parsePathFromDiff(diff: string): string | undefined {
463534export function splitUnifiedDiffByFile(diff: string): Map<string, string> {
464535 const normalized = diff.replace(/\r\n/g, "\n");
465536 const byPath = new Map<string, string>();
466 const headerMatches = [...normalized.matchAll(/^diff --git a\/(.+) b\/(.+)$/gm)];
537 const headerMatches = [...normalized.matchAll(/^diff --git .+$/gm)];
467538
468539 if (headerMatches.length === 0) {
469540 const path = parsePathFromDiff(normalized);
......@@ -479,7 +550,7 @@ export function splitUnifiedDiffByFile(diff: string): Map<string, string> {
479550 const start = match.index ?? 0;
480551 const nextStart = headerMatches[index + 1]?.index ?? normalized.length;
481552 const segment = normalized.slice(start, nextStart).trim();
482 const path = match[2] ?? match[1] ?? parsePathFromDiff(segment);
553 const path = parsePathFromDiff(segment);
483554 if (!path || segment.length === 0) continue;
484555 byPath.set(path, segment);
485556 }
......@@ -555,7 +626,15 @@ export function countDiffStat(patch: string | undefined): { additions: number; d
555626}
556627
557628export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummary[] {
558 const ordered = [...events].toReversed();
629 const firstEventAt = Date.parse(events[0]?.createdAt ?? "");
630 const lastEventAt = Date.parse(events.at(-1)?.createdAt ?? "");
631 const ordered =
632 events.length < 2 ||
633 Number.isNaN(firstEventAt) ||
634 Number.isNaN(lastEventAt) ||
635 firstEventAt >= lastEventAt
636 ? events
637 : [...events].toReversed();
559638 const byTurnId = new Map<string, MutableTurnDiffSummary>();
560639
561640 const ensureSummary = (turnId: string): MutableTurnDiffSummary => {
......@@ -566,6 +645,8 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar
566645 completedAt: undefined,
567646 status: undefined,
568647 assistantMessageId: undefined,
648 unifiedDiff: undefined,
649 filesByPath: new Map<string, TurnDiffFileChange>(),
569650 };
570651 byTurnId.set(turnId, next);
571652 return next;
......@@ -577,21 +658,38 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar
577658 const summary = ensureSummary(turnId);
578659
579660 if (event.method === "turn/completed") {
580 summary.completedAt = event.createdAt;
661 if (summary.completedAt === undefined) {
662 summary.completedAt = event.createdAt;
663 }
581664 const turn = asObject(asObject(event.payload)?.turn);
582 summary.status = normalizeDetail(asString(turn?.status));
583 continue;
665 if (summary.status === undefined) {
666 summary.status = normalizeDetail(asString(turn?.status));
667 }
584668 }
585669
586670 if (event.method === "item/completed") {
587671 const item = asObject(asObject(event.payload)?.item);
588672 if (asString(item?.type) === "agentMessage") {
589673 const itemId = normalizeDetail(asString(item?.id));
590 if (itemId) {
674 if (itemId && summary.assistantMessageId === undefined) {
591675 summary.assistantMessageId = itemId;
592676 }
593677 }
594678 }
679
680 if (event.method === "turn/diff/updated" && summary.unifiedDiff === undefined) {
681 const diff = normalizeDetail(asString(asObject(event.payload)?.diff));
682 if (diff) {
683 summary.unifiedDiff = diff;
684 for (const file of deriveTurnDiffFilesFromUnifiedDiff(diff)) {
685 mergeTurnDiffFileChange(summary.filesByPath, file);
686 }
687 }
688 }
689
690 for (const file of parseFileChangeEntriesFromEvent(event)) {
691 mergeTurnDiffFileChange(summary.filesByPath, file);
692 }
595693 }
596694
597695 const summaries: TurnDiffSummary[] = [];
......@@ -602,7 +700,10 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar
602700 turnId: summary.turnId,
603701 completedAt: summary.completedAt,
604702 ...(summary.status ? { status: summary.status } : {}),
605 files: [],
703 files: Array.from(summary.filesByPath.values()).toSorted((a, b) =>
704 a.path.localeCompare(b.path),
705 ),
706 ...(summary.unifiedDiff ? { unifiedDiff: summary.unifiedDiff } : {}),
606707 ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}),
607708 });
608709 }
apps/web/src/store.test.ts+17
......@@ -569,6 +569,23 @@ describe("store reducer thread continuity", () => {
569569 ]);
570570 });
571571
572 it("infers checkpoint turn counts when deriving turn summaries from an empty baseline", () => {
573 const state = makeState(makeThread({ turnDiffSummaries: [] }));
574 const next = reducer(state, {
575 type: "APPLY_EVENT",
576 event: makeEvent({
577 method: "turn/completed",
578 turnId: "turn-1",
579 createdAt: "2026-02-09T00:00:03.000Z",
580 payload: { turn: { id: "turn-1", status: "completed" } },
581 }),
582 activeAssistantItemRef: { current: null },
583 });
584
585 expect(next.threads[0]?.turnDiffSummaries[0]?.turnId).toBe("turn-1");
586 expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointTurnCount).toBe(1);
587 });
588
572589 it("reconciles project ids by cwd when syncing backend projects", () => {
573590 const state: AppState = {
574591 projects: [
apps/web/src/store.ts+10-6
......@@ -202,7 +202,6 @@ function mergeTurnDiffSummaries(
202202 existing: Thread["turnDiffSummaries"],
203203 next: Thread["turnDiffSummaries"],
204204): Thread["turnDiffSummaries"] {
205 if (existing.length === 0) return next;
206205 if (next.length === 0) return existing;
207206
208207 const existingByTurnId = new Map(existing.map((summary) => [summary.turnId, summary] as const));
......@@ -893,11 +892,16 @@ export function reducer(state: AppState, action: Action): AppState {
893892 ...state,
894893 threads: updateThread(state.threads, target.id, (t) => {
895894 const nextEvents = [event, ...t.events];
896 const derivedTurnDiffSummaries = deriveTurnDiffSummaries(nextEvents);
897 const turnDiffSummaries = mergeTurnDiffSummaries(
898 t.turnDiffSummaries,
899 derivedTurnDiffSummaries,
900 );
895 const itemType = asString(asObject(asObject(event.payload)?.item)?.type);
896 const normalizedItemType = itemType?.replace(/[_-]/g, "").toLowerCase();
897 const shouldRederiveDiffs =
898 event.method === "turn/completed" ||
899 event.method === "turn/diff/updated" ||
900 (event.method === "item/completed" &&
901 (normalizedItemType === "agentmessage" || normalizedItemType === "filechange"));
902 const turnDiffSummaries = shouldRederiveDiffs
903 ? mergeTurnDiffSummaries(t.turnDiffSummaries, deriveTurnDiffSummaries(nextEvents))
904 : t.turnDiffSummaries;
901905 const eventThreadId = getEventThreadId(event);
902906 const shouldRebindIdentity =
903907 event.method === "thread/started" && t.session?.status === "connecting";