History

Add checkpoint diff API and hydrate turn diffs in UI

- add server support to diff filesystem checkpoints, including root/HEAD fallbacks and lazy checkpoint initialization - wire new `providers.getCheckpointDiff` WebSocket route through provider manager and contracts - update web app to load per-turn diffs from checkpoints and improve diff panel behavior on narrow screens

Julius Marminge committed 8 months ago

Commit cf8a2f01 parent 582615927 files · +2267 −480Browse files

Unified · Split

27 files
  1. apps/server/src/codexAppServerManager.ts +4 −3
  2. apps/server/src/filesystemCheckpointStore.test.ts +75 −0
  3. apps/server/src/filesystemCheckpointStore.ts +79 −1
  4. apps/server/src/providerManager.test.ts +337 −0
  5. apps/server/src/providerManager.ts +105 −5
  6. apps/server/src/wsServer.test.ts +8 −0
  7. apps/server/src/wsServer.ts +3 −0
  8. apps/web/src/App.tsx +34 −3
  9. apps/web/src/components/ChatView.tsx +427 −238
  10. apps/web/src/components/DiffPanel.tsx +475 −90
  11. apps/web/src/components/Sidebar.tsx +1 −0
  12. apps/web/src/hooks/useMediaQuery.ts +27 −0
  13. apps/web/src/persistenceSchema.test.ts +98 −0
  14. apps/web/src/persistenceSchema.ts +50 −0
  15. apps/web/src/session-logic.test.ts +53 −3
  16. apps/web/src/session-logic.ts +65 −114
  17. apps/web/src/store.test.ts +139 −0
  18. apps/web/src/store.ts +201 −22
  19. apps/web/src/types.ts +20 −0
  20. apps/web/src/worktreeCleanup.test.ts +1 −0
  21. apps/web/src/wsNativeApi.ts +1 −0
  22. apps/web/src/wsTransport.ts +3 −0
  23. packages/contracts/package.json +1 −1
  24. packages/contracts/src/ipc.ts +5 −0
  25. packages/contracts/src/provider.test.ts +29 −0
  26. packages/contracts/src/provider.ts +25 −0
  27. packages/contracts/src/ws.ts +1 −0
apps/server/src/codexAppServerManager.ts+4−3
@@ -148,19 +148,20 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 147 unmodified lines
148148 async startSession(input: ProviderSessionStartInput): Promise<ProviderSession> {
149149 const sessionId = randomUUID();
150150 const now = new Date().toISOString();
151+ const resolvedCwd = input.cwd ?? process.cwd();
151152
152153 const session: ProviderSession = {
153154 sessionId,
154155 provider: "codex",
155156 status: "connecting",
156157 model: normalizeCodexModelSlug(input.model),
157− cwd: input.cwd,
158+ cwd: resolvedCwd,
158159 createdAt: now,
159160 updatedAt: now,
160161 };
161162
162163 const child = spawn("codex", ["app-server"], {
163− cwd: input.cwd,
164+ cwd: resolvedCwd,
164165 env: process.env,
165166 stdio: ["pipe", "pipe", "pipe"],
166167 });
@@ -198,7 +199,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 31 unmodified lines
198199 const normalizedModel = normalizeCodexModelSlug(input.model);
199200 const sessionOverrides = {
200201 model: normalizedModel ?? null,
201− cwd: input.cwd ?? null,
202+ cwd: resolvedCwd,
202203 approvalPolicy: input.approvalPolicy,
203204 sandbox: input.sandboxMode,
204205 };
apps/server/src/filesystemCheckpointStore.test.ts+75−0
@@ -93,6 +93,32 @@ describe("FilesystemCheckpointStore", () => { · 92 unmodified lines
9393 }
9494 });
9595
96+ it("falls back to HEAD when restoring turn 0 and root checkpoint ref is missing", async () => {
97+ const cwd = await createRepo();
98+ const store = new FilesystemCheckpointStore();
99+ const threadId = "thr_2b";
100+
101+ try {
102+ await writeFile(path.join(cwd, "README.md"), "v2\n");
103+ await store.captureCheckpoint({ cwd, threadId, turnCount: 1 });
104+
105+ await writeFile(path.join(cwd, "README.md"), "v3\n");
106+ await writeFile(path.join(cwd, "scratch.txt"), "scratch\n");
107+
108+ const restored = await store.restoreCheckpoint({
109+ cwd,
110+ threadId,
111+ turnCount: 0,
112+ });
113+
114+ expect(restored).toBe(true);
115+ expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("v1\n");
116+ expect(exists(path.join(cwd, "scratch.txt"))).toBe(false);
117+ } finally {
118+ await rm(cwd, { recursive: true, force: true });
119+ }
120+ });
121+
96122 it("prunes refs newer than the retained turn", async () => {
97123 const cwd = await createRepo();
98124 const store = new FilesystemCheckpointStore();
@@ -119,6 +145,55 @@ describe("FilesystemCheckpointStore", () => { · 20 unmodified lines
119145 }
120146 });
121147
148+ it("returns a unified patch between checkpoint turns", async () => {
149+ const cwd = await createRepo();
150+ const store = new FilesystemCheckpointStore();
151+ const threadId = "thr_4";
152+
153+ try {
154+ await store.captureCheckpoint({ cwd, threadId, turnCount: 0 });
155+ await writeFile(path.join(cwd, "README.md"), "v2\n");
156+ await writeFile(path.join(cwd, "new.txt"), "new file\n");
157+ await store.captureCheckpoint({ cwd, threadId, turnCount: 1 });
158+
159+ const diff = await store.diffCheckpoints({
160+ cwd,
161+ threadId,
162+ fromTurnCount: 0,
163+ toTurnCount: 1,
164+ });
165+
166+ expect(diff).toContain("diff --git a/README.md b/README.md");
167+ expect(diff).toContain("diff --git a/new.txt b/new.txt");
168+ } finally {
169+ await rm(cwd, { recursive: true, force: true });
170+ }
171+ });
172+
173+ it("falls back to HEAD as diff base when turn 0 checkpoint is missing", async () => {
174+ const cwd = await createRepo();
175+ const store = new FilesystemCheckpointStore();
176+ const threadId = "thr_5";
177+
178+ try {
179+ await writeFile(path.join(cwd, "README.md"), "v2\n");
180+ await writeFile(path.join(cwd, "new.txt"), "new file\n");
181+ await store.captureCheckpoint({ cwd, threadId, turnCount: 1 });
182+
183+ const diff = await store.diffCheckpoints({
184+ cwd,
185+ threadId,
186+ fromTurnCount: 0,
187+ toTurnCount: 1,
188+ });
189+
190+ expect(diff).toContain("diff --git a/README.md b/README.md");
191+ expect(diff).toContain("diff --git a/new.txt b/new.txt");
192+ } finally {
193+ await rm(cwd, { recursive: true, force: true });
194+ }
195+ });
196+
122197 it("detects non-repo folders", async () => {
123198 const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoints-nonrepo-"));
124199 const store = new FilesystemCheckpointStore();
apps/server/src/filesystemCheckpointStore.ts+79−1
@@ -91,10 +91,30 @@ export class FilesystemCheckpointStore { · 90 unmodified lines
9191 return commit !== null;
9292 }
9393
94+ async ensureRootCheckpoint(input: { cwd: string; threadId: string }): Promise<boolean> {
95+ const { cwd, threadId } = input;
96+ const rootRef = checkpointRefForThreadTurn(threadId, 0);
97+ const existing = await this.resolveCheckpointCommit(cwd, rootRef);
98+ if (existing) {
99+ return true;
100+ }
101+
102+ const headCommit = await this.resolveHeadCommit(cwd);
103+ if (!headCommit) {
104+ return false;
105+ }
106+
107+ await this.runGit(cwd, ["update-ref", rootRef, headCommit]);
108+ return true;
109+ }
110+
94111 async restoreCheckpoint(input: { cwd: string; threadId: string; turnCount: number }): Promise<boolean> {
95112 const { cwd, threadId, turnCount } = input;
96113 const ref = checkpointRefForThreadTurn(threadId, turnCount);
97− const commitOid = await this.resolveCheckpointCommit(cwd, ref);
114+ let commitOid = await this.resolveCheckpointCommit(cwd, ref);
115+ if (!commitOid && turnCount === 0) {
116+ commitOid = await this.resolveHeadCommit(cwd);
117+ }
98118 if (!commitOid) {
99119 return false;
100120 }
@@ -105,6 +125,64 @@ export class FilesystemCheckpointStore { · 4 unmodified lines
105125 return true;
106126 }
107127
128+ async diffCheckpoints(input: {
129+ cwd: string;
130+ threadId: string;
131+ fromTurnCount: number;
132+ toTurnCount: number;
133+ }): Promise<string> {
134+ const { cwd, threadId, fromTurnCount, toTurnCount } = input;
135+ if (!Number.isInteger(fromTurnCount) || fromTurnCount < 0) {
136+ throw new Error(`Invalid from turn count: ${fromTurnCount}`);
137+ }
138+ if (!Number.isInteger(toTurnCount) || toTurnCount < 0) {
139+ throw new Error(`Invalid to turn count: ${toTurnCount}`);
140+ }
141+ if (fromTurnCount > toTurnCount) {
142+ throw new Error(`Invalid checkpoint range: ${fromTurnCount}..${toTurnCount}`);
143+ }
144+
145+ const fromRef = checkpointRefForThreadTurn(threadId, fromTurnCount);
146+ const toRef = checkpointRefForThreadTurn(threadId, toTurnCount);
147+ let fromCommitOid = await this.resolveCheckpointCommit(cwd, fromRef);
148+ const toCommitOid = await this.resolveCheckpointCommit(cwd, toRef);
149+ if (!fromCommitOid && fromTurnCount === 0) {
150+ const headCommit = await this.resolveHeadCommit(cwd);
151+ if (headCommit) {
152+ fromCommitOid = headCommit;
153+ }
154+ }
155+ if (!fromCommitOid) {
156+ throw new Error(
157+ `Filesystem checkpoint is unavailable for turn ${fromTurnCount} in thread ${threadId}.`,
158+ );
159+ }
160+ if (!toCommitOid) {
161+ throw new Error(`Filesystem checkpoint is unavailable for turn ${toTurnCount} in thread ${threadId}.`);
162+ }
163+
164+ const result = await this.runGit(cwd, [
165+ "diff",
166+ "--patch",
167+ "--minimal",
168+ "--no-color",
169+ fromCommitOid,
170+ toCommitOid,
171+ ]);
172+ return result.stdout;
173+ }
174+
175+ private async resolveHeadCommit(cwd: string): Promise<string | null> {
176+ const result = await this.runGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], {
177+ allowNonZeroExit: true,
178+ });
179+ if (result.code !== 0) {
180+ return null;
181+ }
182+ const commit = result.stdout.trim();
183+ return commit.length > 0 ? commit : null;
184+ }
185+
108186 async pruneAfterTurn(input: { cwd: string; threadId: string; maxTurnCount: number }): Promise<void> {
109187 const { cwd, threadId, maxTurnCount } = input;
110188 if (!Number.isInteger(maxTurnCount) || maxTurnCount < 0) {
apps/server/src/providerManager.test.ts+337−0
@@ -76,6 +76,14 @@ describe("ProviderManager", () => { · 75 unmodified lines
7676 }),
7777 ).rejects.toThrow("Unknown provider session: missing-session");
7878
79+ await expect(
80+ manager.getCheckpointDiff({
81+ sessionId: "missing-session",
82+ fromTurnCount: 0,
83+ toTurnCount: 1,
84+ }),
85+ ).rejects.toThrow("Unknown provider session: missing-session");
86+
7987 manager.dispose();
8088 });
8189
@@ -155,12 +163,39 @@ describe("ProviderManager", () => { · 73 unmodified lines
155163 threadId: string;
156164 turns: Array<{ id: string; items: unknown[] }>;
157165 }>;
166+ listSessions: () => Array<{
167+ sessionId: string;
168+ provider: "codex";
169+ status: "ready";
170+ createdAt: string;
171+ updatedAt: string;
172+ cwd?: string;
173+ }>;
174+ };
175+ filesystemCheckpointStore: {
176+ isGitRepository: (cwd: string) => Promise<boolean>;
177+ ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise<boolean>;
158178 };
159179 }
160180 ).codex;
181+ const internals = manager as unknown as {
182+ filesystemCheckpointStore: {
183+ isGitRepository: (cwd: string) => Promise<boolean>;
184+ ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise<boolean>;
185+ };
186+ };
161187
162188 const rollbackCalls: Array<{ sessionId: string; numTurns: number }> = [];
163189 codex.hasSession = () => true;
190+ codex.listSessions = () => [
191+ {
192+ sessionId: "sess_1",
193+ provider: "codex",
194+ status: "ready",
195+ createdAt: "2026-02-18T00:00:00.000Z",
196+ updatedAt: "2026-02-18T00:00:00.000Z",
197+ },
198+ ];
164199 codex.readThread = async () => ({
165200 threadId: "thr_1",
166201 turns: [
@@ -176,6 +211,8 @@ describe("ProviderManager", () => { · 9 unmodified lines
176211 turns: [{ id: "turn_1", items: [] }],
177212 };
178213 };
214+ internals.filesystemCheckpointStore.isGitRepository = async () => false;
215+ internals.filesystemCheckpointStore.ensureRootCheckpoint = async () => true;
179216
180217 const result = await manager.revertToCheckpoint({
181218 sessionId: "sess_1",
@@ -192,6 +229,239 @@ describe("ProviderManager", () => { · 10 unmodified lines
192229 manager.dispose();
193230 });
194231
232+ it("returns diff text for a checkpoint range", async () => {
233+ const manager = new ProviderManager();
234+ const internals = manager as unknown as {
235+ codex: {
236+ hasSession: (sessionId: string) => boolean;
237+ readThread: (sessionId: string) => Promise<{
238+ threadId: string;
239+ turns: Array<{ id: string; items: unknown[] }>;
240+ }>;
241+ };
242+ filesystemCheckpointStore: {
243+ diffCheckpoints: (input: {
244+ cwd: string;
245+ threadId: string;
246+ fromTurnCount: number;
247+ toTurnCount: number;
248+ }) => Promise<string>;
249+ };
250+ sessionCheckpointCwds: Map<string, string>;
251+ };
252+
253+ internals.codex.hasSession = () => true;
254+ internals.codex.readThread = async () => ({
255+ threadId: "thr_1",
256+ turns: [{ id: "turn_1", items: [] }, { id: "turn_2", items: [] }],
257+ });
258+ const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts");
259+ internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints;
260+ internals.sessionCheckpointCwds.set("sess_1", "/repo");
261+
262+ const result = await manager.getCheckpointDiff({
263+ sessionId: "sess_1",
264+ fromTurnCount: 1,
265+ toTurnCount: 2,
266+ });
267+
268+ expect(result).toEqual({
269+ threadId: "thr_1",
270+ fromTurnCount: 1,
271+ toTurnCount: 2,
272+ diff: "diff --git a/a.ts b/a.ts",
273+ });
274+ expect(diffCheckpoints).toHaveBeenCalledWith({
275+ cwd: "/repo",
276+ threadId: "thr_1",
277+ fromTurnCount: 1,
278+ toTurnCount: 2,
279+ });
280+
281+ manager.dispose();
282+ });
283+
284+ it("lazily initializes filesystem checkpoints before diffing when cache is missing", async () => {
285+ const manager = new ProviderManager();
286+ const internals = manager as unknown as {
287+ codex: {
288+ hasSession: (sessionId: string) => boolean;
289+ listSessions: () => Array<{
290+ sessionId: string;
291+ provider: "codex";
292+ status: "ready";
293+ createdAt: string;
294+ updatedAt: string;
295+ cwd?: string;
296+ }>;
297+ readThread: (sessionId: string) => Promise<{
298+ threadId: string;
299+ turns: Array<{ id: string; items: unknown[] }>;
300+ }>;
301+ };
302+ filesystemCheckpointStore: {
303+ isGitRepository: (cwd: string) => Promise<boolean>;
304+ ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise<boolean>;
305+ captureCheckpoint: (input: {
306+ cwd: string;
307+ threadId: string;
308+ turnCount: number;
309+ }) => Promise<void>;
310+ diffCheckpoints: (input: {
311+ cwd: string;
312+ threadId: string;
313+ fromTurnCount: number;
314+ toTurnCount: number;
315+ }) => Promise<string>;
316+ };
317+ sessionCheckpointCwds: Map<string, string>;
318+ };
319+
320+ internals.codex.hasSession = () => true;
321+ internals.codex.listSessions = () => [
322+ {
323+ sessionId: "sess_1",
324+ provider: "codex",
325+ status: "ready",
326+ createdAt: "2026-02-18T00:00:00.000Z",
327+ updatedAt: "2026-02-18T00:00:00.000Z",
328+ cwd: "/repo",
329+ },
330+ ];
331+ internals.codex.readThread = async () => ({
332+ threadId: "thr_1",
333+ turns: [{ id: "turn_1", items: [] }, { id: "turn_2", items: [] }],
334+ });
335+ const isGitRepository = vi.fn(async () => true);
336+ const ensureRootCheckpoint = vi.fn(async () => true);
337+ const captureCheckpoint = vi.fn(async () => undefined);
338+ const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts");
339+ internals.filesystemCheckpointStore.isGitRepository = isGitRepository;
340+ internals.filesystemCheckpointStore.ensureRootCheckpoint = ensureRootCheckpoint;
341+ internals.filesystemCheckpointStore.captureCheckpoint = captureCheckpoint;
342+ internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints;
343+ internals.sessionCheckpointCwds.delete("sess_1");
344+
345+ const result = await manager.getCheckpointDiff({
346+ sessionId: "sess_1",
347+ fromTurnCount: 1,
348+ toTurnCount: 2,
349+ });
350+
351+ expect(result.diff).toBe("diff --git a/a.ts b/a.ts");
352+ expect(isGitRepository).toHaveBeenCalledWith("/repo");
353+ expect(captureCheckpoint).toHaveBeenCalledWith({
354+ cwd: "/repo",
355+ threadId: "thr_1",
356+ turnCount: 2,
357+ });
358+ expect(ensureRootCheckpoint).toHaveBeenCalledWith({
359+ cwd: "/repo",
360+ threadId: "thr_1",
361+ });
362+ expect(diffCheckpoints).toHaveBeenCalledWith({
363+ cwd: "/repo",
364+ threadId: "thr_1",
365+ fromTurnCount: 1,
366+ toTurnCount: 2,
367+ });
368+ expect(internals.sessionCheckpointCwds.get("sess_1")).toBe("/repo");
369+
370+ manager.dispose();
371+ });
372+
373+ it("falls back to process cwd for lazy checkpoint init when session cwd is missing", async () => {
374+ const manager = new ProviderManager();
375+ const internals = manager as unknown as {
376+ codex: {
377+ hasSession: (sessionId: string) => boolean;
378+ listSessions: () => Array<{
379+ sessionId: string;
380+ provider: "codex";
381+ status: "ready";
382+ createdAt: string;
383+ updatedAt: string;
384+ cwd?: string;
385+ }>;
386+ readThread: (sessionId: string) => Promise<{
387+ threadId: string;
388+ turns: Array<{ id: string; items: unknown[] }>;
389+ }>;
390+ };
391+ filesystemCheckpointStore: {
392+ isGitRepository: (cwd: string) => Promise<boolean>;
393+ ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise<boolean>;
394+ captureCheckpoint: (input: {
395+ cwd: string;
396+ threadId: string;
397+ turnCount: number;
398+ }) => Promise<void>;
399+ diffCheckpoints: (input: {
400+ cwd: string;
401+ threadId: string;
402+ fromTurnCount: number;
403+ toTurnCount: number;
404+ }) => Promise<string>;
405+ };
406+ sessionCheckpointCwds: Map<string, string>;
407+ };
408+
409+ const processCwd = vi.spyOn(process, "cwd").mockReturnValue("/fallback-repo");
410+ try {
411+ internals.codex.hasSession = () => true;
412+ internals.codex.listSessions = () => [
413+ {
414+ sessionId: "sess_1",
415+ provider: "codex",
416+ status: "ready",
417+ createdAt: "2026-02-18T00:00:00.000Z",
418+ updatedAt: "2026-02-18T00:00:00.000Z",
419+ },
420+ ];
421+ internals.codex.readThread = async () => ({
422+ threadId: "thr_1",
423+ turns: [{ id: "turn_1", items: [] }],
424+ });
425+ const isGitRepository = vi.fn(async (cwd: string) => cwd === "/fallback-repo");
426+ const ensureRootCheckpoint = vi.fn(async () => true);
427+ const captureCheckpoint = vi.fn(async () => undefined);
428+ const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts");
429+ internals.filesystemCheckpointStore.isGitRepository = isGitRepository;
430+ internals.filesystemCheckpointStore.ensureRootCheckpoint = ensureRootCheckpoint;
431+ internals.filesystemCheckpointStore.captureCheckpoint = captureCheckpoint;
432+ internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints;
433+ internals.sessionCheckpointCwds.delete("sess_1");
434+
435+ const result = await manager.getCheckpointDiff({
436+ sessionId: "sess_1",
437+ fromTurnCount: 0,
438+ toTurnCount: 1,
439+ });
440+
441+ expect(result.diff).toBe("diff --git a/a.ts b/a.ts");
442+ expect(isGitRepository).toHaveBeenCalledWith("/fallback-repo");
443+ expect(captureCheckpoint).toHaveBeenCalledWith({
444+ cwd: "/fallback-repo",
445+ threadId: "thr_1",
446+ turnCount: 1,
447+ });
448+ expect(ensureRootCheckpoint).toHaveBeenCalledWith({
449+ cwd: "/fallback-repo",
450+ threadId: "thr_1",
451+ });
452+ expect(diffCheckpoints).toHaveBeenCalledWith({
453+ cwd: "/fallback-repo",
454+ threadId: "thr_1",
455+ fromTurnCount: 0,
456+ toTurnCount: 1,
457+ });
458+ expect(internals.sessionCheckpointCwds.get("sess_1")).toBe("/fallback-repo");
459+ } finally {
460+ processCwd.mockRestore();
461+ manager.dispose();
462+ }
463+ });
464+
195465 it("restores filesystem checkpoint when reverting and checkpointing is enabled", async () => {
196466 const manager = new ProviderManager();
197467 const internals = manager as unknown as {
@@ -263,6 +533,73 @@ describe("ProviderManager", () => { · 65 unmodified lines
263533 manager.dispose();
264534 });
265535
536+ it("reverts to turn 0 without requiring an explicit turn 0 checkpoint ref", async () => {
537+ const manager = new ProviderManager();
538+ const internals = manager as unknown as {
539+ codex: {
540+ hasSession: (sessionId: string) => boolean;
541+ readThread: (sessionId: string) => Promise<{
542+ threadId: string;
543+ turns: Array<{ id: string; items: unknown[] }>;
544+ }>;
545+ rollbackThread: (sessionId: string, numTurns: number) => Promise<{
546+ threadId: string;
547+ turns: Array<{ id: string; items: unknown[] }>;
548+ }>;
549+ };
550+ filesystemCheckpointStore: {
551+ hasCheckpoint: (input: { cwd: string; threadId: string; turnCount: number }) => Promise<boolean>;
552+ restoreCheckpoint: (input: {
553+ cwd: string;
554+ threadId: string;
555+ turnCount: number;
556+ }) => Promise<boolean>;
557+ pruneAfterTurn: (input: { cwd: string; threadId: string; maxTurnCount: number }) => Promise<void>;
558+ };
559+ sessionCheckpointCwds: Map<string, string>;
560+ };
561+
562+ internals.codex.hasSession = () => true;
563+ internals.codex.readThread = async () => ({
564+ threadId: "thr_1",
565+ turns: [
566+ { id: "turn_1", items: [] },
567+ { id: "turn_2", items: [] },
568+ ],
569+ });
570+ internals.codex.rollbackThread = async () => ({
571+ threadId: "thr_1",
572+ turns: [],
573+ });
574+ const hasCheckpoint = vi.fn(async () => false);
575+ const restoreCheckpoint = vi.fn(async () => true);
576+ const pruneAfterTurn = vi.fn(async () => undefined);
577+ internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint;
578+ internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint;
579+ internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn;
580+ internals.sessionCheckpointCwds.set("sess_1", "/repo");
581+
582+ const result = await manager.revertToCheckpoint({
583+ sessionId: "sess_1",
584+ turnCount: 0,
585+ });
586+
587+ expect(result.turnCount).toBe(0);
588+ expect(hasCheckpoint).not.toHaveBeenCalled();
589+ expect(restoreCheckpoint).toHaveBeenCalledWith({
590+ cwd: "/repo",
591+ threadId: "thr_1",
592+ turnCount: 0,
593+ });
594+ expect(pruneAfterTurn).toHaveBeenCalledWith({
595+ cwd: "/repo",
596+ threadId: "thr_1",
597+ maxTurnCount: 0,
598+ });
599+
600+ manager.dispose();
601+ });
602+
266603 it("fails before rollback when filesystem checkpoint is missing", async () => {
267604 const manager = new ProviderManager();
268605 const internals = manager as unknown as {
apps/server/src/providerManager.ts+105−5
@@ -6,6 +6,8 @@ import { randomUUID } from "node:crypto"; · 5 unmodified lines
66 import {
77 type ProviderCheckpoint,
88 type ProviderEvent,
9+ type ProviderGetCheckpointDiffInput,
10+ type ProviderGetCheckpointDiffResult,
911 type ProviderInterruptTurnInput,
1012 type ProviderListCheckpointsInput,
1113 type ProviderListCheckpointsResult,
@@ -18,6 +20,7 @@ import { · 6 unmodified lines
1820 type ProviderStopSessionInput,
1921 type ProviderTurnStartResult,
2022 providerInterruptTurnInputSchema,
23+ providerGetCheckpointDiffInputSchema,
2124 providerListCheckpointsInputSchema,
2225 providerRevertToCheckpointInputSchema,
2326 providerRespondToRequestInputSchema,
@@ -198,7 +201,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 174 unmodified lines
198201 this.sessionThreadIds.set(session.sessionId, session.threadId);
199202 this.flushPendingSessionEvents(session.sessionId, session.threadId);
200203 }
201− await this.initializeFilesystemCheckpointing(session).catch((error) => {
204+ await this.initializeFilesystemCheckpointing(session, input.cwd).catch((error) => {
202205 const message =
203206 error instanceof Error ? error.message : "Failed to initialize filesystem checkpoints.";
204207 this.emitFilesystemCheckpointError(session.sessionId, message, session.threadId);
@@ -259,6 +262,43 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 54 unmodified lines
259262 };
260263 }
261264
265+ async getCheckpointDiff(
266+ raw: ProviderGetCheckpointDiffInput,
267+ ): Promise<ProviderGetCheckpointDiffResult> {
268+ const input = providerGetCheckpointDiffInputSchema.parse(raw);
269+ if (!this.codex.hasSession(input.sessionId)) {
270+ throw new Error(`Unknown provider session: ${input.sessionId}`);
271+ }
272+
273+ const checkpointCwd = await this.getOrInitializeFilesystemCheckpointCwd(input.sessionId);
274+ if (!checkpointCwd) {
275+ throw new Error("Filesystem checkpoints are unavailable for this session.");
276+ }
277+
278+ return this.withFilesystemLock(input.sessionId, async () => {
279+ const snapshot = await this.codex.readThread(input.sessionId);
280+ if (input.toTurnCount > snapshot.turns.length) {
281+ throw new Error(
282+ `Checkpoint turn count ${input.toTurnCount} exceeds current turn count ${snapshot.turns.length}.`,
283+ );
284+ }
285+
286+ const diff = await this.filesystemCheckpointStore.diffCheckpoints({
287+ cwd: checkpointCwd,
288+ threadId: snapshot.threadId,
289+ fromTurnCount: input.fromTurnCount,
290+ toTurnCount: input.toTurnCount,
291+ });
292+
293+ return {
294+ threadId: snapshot.threadId,
295+ fromTurnCount: input.fromTurnCount,
296+ toTurnCount: input.toTurnCount,
297+ diff,
298+ };
299+ });
300+ }
301+
262302 async revertToCheckpoint(
263303 raw: ProviderRevertToCheckpointInput,
264304 ): Promise<ProviderRevertToCheckpointResult> {
@@ -267,6 +307,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 2 unmodified lines
267307 throw new Error(`Unknown provider session: ${input.sessionId}`);
268308 }
269309
310+ const checkpointCwd = await this.getOrInitializeFilesystemCheckpointCwd(input.sessionId);
270311 return this.withFilesystemLock(input.sessionId, async () => {
271312 const beforeSnapshot = await this.codex.readThread(input.sessionId);
272313 const currentTurnCount = beforeSnapshot.turns.length;
@@ -276,8 +317,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 3 unmodified lines
276317 );
277318 }
278319
279− const checkpointCwd = this.sessionCheckpointCwds.get(input.sessionId);
280− if (checkpointCwd) {
320+ if (checkpointCwd && input.turnCount > 0) {
281321 const hasCheckpoint = await this.filesystemCheckpointStore.hasCheckpoint({
282322 cwd: checkpointCwd,
283323 threadId: beforeSnapshot.threadId,
@@ -352,8 +392,11 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 68 unmodified lines
352392 this.filesystemLocks.clear();
353393 }
354394
355− private async initializeFilesystemCheckpointing(session: ProviderSession): Promise<void> {
356− const cwd = session.cwd ?? process.cwd();
395+ private async initializeFilesystemCheckpointing(
396+ session: ProviderSession,
397+ preferredCwd?: string,
398+ ): Promise<void> {
399+ const cwd = preferredCwd ?? session.cwd ?? process.cwd();
357400
358401 await this.withFilesystemLock(session.sessionId, async () => {
359402 const supportsGit = await this.filesystemCheckpointStore.isGitRepository(cwd);
@@ -363,6 +406,10 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 3 unmodified lines
363406 }
364407
365408 const snapshot = await this.codex.readThread(session.sessionId);
409+ await this.filesystemCheckpointStore.ensureRootCheckpoint({
410+ cwd,
411+ threadId: snapshot.threadId,
412+ });
366413 await this.filesystemCheckpointStore.captureCheckpoint({
367414 cwd,
368415 threadId: snapshot.threadId,
@@ -372,6 +419,54 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 3 unmodified lines
372419 });
373420 }
374421
422+ private async getOrInitializeFilesystemCheckpointCwd(sessionId: string): Promise<string | null> {
423+ const existingCwd = this.sessionCheckpointCwds.get(sessionId);
424+ if (existingCwd) {
425+ return existingCwd;
426+ }
427+
428+ const session = this.codex.listSessions().find((candidate) => candidate.sessionId === sessionId);
429+ const candidateCwds = Array.from(
430+ new Set([session?.cwd, process.cwd()].filter((value): value is string => Boolean(value))),
431+ );
432+ if (candidateCwds.length === 0) {
433+ return null;
434+ }
435+
436+ await this.withFilesystemLock(sessionId, async () => {
437+ const currentCwd = this.sessionCheckpointCwds.get(sessionId);
438+ if (currentCwd) {
439+ return;
440+ }
441+
442+ const cwdSupport = await Promise.all(
443+ candidateCwds.map(async (cwd) => ({
444+ cwd,
445+ supportsGit: await this.filesystemCheckpointStore.isGitRepository(cwd),
446+ })),
447+ );
448+ const supportedCwd = cwdSupport.find((entry) => entry.supportsGit)?.cwd;
449+ if (supportedCwd) {
450+ const snapshot = await this.codex.readThread(sessionId);
451+ await this.filesystemCheckpointStore.ensureRootCheckpoint({
452+ cwd: supportedCwd,
453+ threadId: snapshot.threadId,
454+ });
455+ await this.filesystemCheckpointStore.captureCheckpoint({
456+ cwd: supportedCwd,
457+ threadId: snapshot.threadId,
458+ turnCount: snapshot.turns.length,
459+ });
460+ this.sessionCheckpointCwds.set(sessionId, supportedCwd);
461+ return;
462+ }
463+
464+ this.sessionCheckpointCwds.delete(sessionId);
465+ });
466+
467+ return this.sessionCheckpointCwds.get(sessionId) ?? null;
468+ }
469+
375470 private maybeCaptureFilesystemCheckpoint(event: ProviderEvent): void {
376471 if (event.kind !== "notification" || event.method !== "turn/completed") {
377472 return;
@@ -379,6 +474,11 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 1 unmodified line
379474
380475 const checkpointCwd = this.sessionCheckpointCwds.get(event.sessionId);
381476 if (!checkpointCwd) {
477+ void this.getOrInitializeFilesystemCheckpointCwd(event.sessionId).catch((error) => {
478+ const message =
479+ error instanceof Error ? error.message : "Failed to initialize filesystem checkpoints.";
480+ this.emitFilesystemCheckpointError(event.sessionId, message, event.threadId);
481+ });
382482 return;
383483 }
384484
apps/server/src/wsServer.test.ts+8−0
@@ -668,6 +668,14 @@ describe("WebSocket Server", () => { · 667 unmodified lines
668668 });
669669 expect(revertResponse.result).toBeUndefined();
670670 expect(revertResponse.error?.message).toContain("Unknown provider session");
671+
672+ const diffResponse = await sendRequest(ws, WS_METHODS.providersGetCheckpointDiff, {
673+ sessionId: "missing-session",
674+ fromTurnCount: 0,
675+ toTurnCount: 1,
676+ });
677+ expect(diffResponse.result).toBeUndefined();
678+ expect(diffResponse.error?.message).toContain("Unknown provider session");
671679 });
672680
673681 it("routes terminal RPC methods and broadcasts terminal events", async () => {
apps/server/src/wsServer.ts+3−0
@@ -313,6 +313,9 @@ export function createServer(options: ServerOptions) { · 312 unmodified lines
313313 case WS_METHODS.providersListCheckpoints:
314314 return providerManager.listCheckpoints(request.params as never);
315315
316+ case WS_METHODS.providersGetCheckpointDiff:
317+ return providerManager.getCheckpointDiff(request.params as never);
318+
316319 case WS_METHODS.providersRevertToCheckpoint:
317320 return providerManager.revertToCheckpoint(request.params as never);
318321
apps/web/src/App.tsx+34−3
@@ -9,10 +9,13 @@ import { StoreProvider, useStore } from "./store"; · 8 unmodified lines
99 import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types";
1010 import { onServerWelcome } from "./wsNativeApi";
1111 import { useNativeApi } from "./hooks/useNativeApi";
12+import { useMediaQuery } from "./hooks/useMediaQuery";
1213 import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast";
14+import { Sheet, SheetPopup } from "./components/ui/sheet";
1315 import { invalidateGitQueries } from "./lib/gitReactQuery";
1416
1517 const DiffPanel = lazy(() => import("./components/DiffPanel"));
18+const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";
1619
1720 function EventRouter() {
1821 const api = useNativeApi();
@@ -112,6 +115,7 @@ function AutoProjectBootstrap() { · 93 unmodified lines
112115 session: null,
113116 messages: [],
114117 events: [],
118+ turnDiffSummaries: [],
115119 error: null,
116120 createdAt: new Date().toISOString(),
117121 branch: null,
@@ -177,7 +181,8 @@ function DesktopProjectBootstrap() { · 59 unmodified lines
177181
178182 function Layout() {
179183 const api = useNativeApi();
180− const { state } = useStore();
184+ const { state, dispatch } = useStore();
185+ const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);
181186
182187 if (!api) {
183188 return (
@@ -196,7 +201,7 @@ function Layout() { · 12 unmodified lines
196201 <DesktopProjectBootstrap />
197202 <Sidebar />
198203 <ChatView />
199− {state.diffOpen && (
204+ {state.diffOpen && !shouldUseDiffSheet && (
200205 <Suspense
201206 fallback={
202207 <aside className="flex h-full w-[560px] shrink-0 items-center justify-center border-l border-border bg-card px-4 text-center text-xs text-muted-foreground/70">
@@ -204,9 +209,35 @@ function Layout() { · 1 unmodified line
204209 </aside>
205210 }
206211 >
207− <DiffPanel />
212+ <DiffPanel mode="inline" />
208213 </Suspense>
209214 )}
215+ {state.diffOpen && shouldUseDiffSheet && (
216+ <Sheet
217+ open={state.diffOpen}
218+ onOpenChange={(open) => {
219+ if (!open) {
220+ dispatch({ type: "CLOSE_DIFF" });
221+ }
222+ }}
223+ >
224+ <SheetPopup
225+ side="right"
226+ showCloseButton={false}
227+ className="w-[min(88vw,820px)] max-w-[820px] p-0"
228+ >
229+ <Suspense
230+ fallback={
231+ <div className="flex h-full min-h-0 items-center justify-center px-4 text-center text-xs text-muted-foreground/70">
232+ Loading diff viewer...
233+ </div>
234+ }
235+ >
236+ <DiffPanel mode="sheet" />
237+ </Suspense>
238+ </SheetPopup>
239+ </Sheet>
240+ )}
210241 </div>
211242 );
212243 }
apps/web/src/components/ChatView.tsx+427−238
@@ -8,7 +8,6 @@ import { · 7 unmodified lines
88 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
99 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1010 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
11− type ProviderCheckpoint,
1211 type ResolvedKeybindingsConfig,
1312 type ProviderApprovalDecision,
1413 type ProviderSendTurnAttachmentInput,
@@ -50,10 +49,12 @@ import { · 35 unmodified lines
5049 resolveModelSlug,
5150 } from "../model-logic";
5251 import {
52+ countDiffStat,
5353 derivePendingApprovals,
5454 derivePhase,
5555 deriveTurnDiffSummaries,
5656 deriveTimelineEntries,
57+ inferCheckpointTurnCountByTurnId,
5758 type TurnDiffSummary,
5859 type PendingApproval,
5960 deriveWorkLogEntries,
@@ -87,6 +88,7 @@ import { · 27 unmodified lines
8788 CircleAlertIcon,
8889 FileIcon,
8990 FolderIcon,
91+ DiffIcon,
9092 FolderClosedIcon,
9193 InfoIcon,
9294 LockIcon,
@@ -348,9 +350,7 @@ export default function ChatView() { · 255 unmodified lines
348350 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
349351 const [isSending, setIsSending] = useState(false);
350352 const [isConnecting, setIsConnecting] = useState(false);
351− const [isLoadingCheckpoints, setIsLoadingCheckpoints] = useState(false);
352353 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
353− const [checkpoints, setCheckpoints] = useState<ProviderCheckpoint[]>([]);
354354 const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING);
355355 const [envMode, setEnvMode] = useState<"local" | "worktree">("local");
356356 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
@@ -371,10 +371,13 @@ export default function ChatView() { · 14 unmodified lines
371371 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
372372 const dragDepthRef = useRef(0);
373373 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
374+ const checkpointDiffRequestsRef = useRef(new Set<string>());
375+ const checkpointHydrationSessionRequestRef = useRef(new Set<string>());
374376
375377 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
376378 const activeThreadId = activeThread?.id ?? null;
377379 const activeSessionId = activeThread?.session?.sessionId;
380+ const activeThreadRuntimeId = activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null;
378381 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
379382 const selectedModel = resolveModelSlug(
380383 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
@@ -409,8 +412,11 @@ export default function ChatView() { · 28 unmodified lines
409412 [activeThread?.messages, workLogEntries],
410413 );
411414 const turnDiffSummaries = useMemo(
412− () => deriveTurnDiffSummaries(activeThread?.events ?? []),
413− [activeThread?.events],
415+ () =>
416+ activeThread?.turnDiffSummaries.length
417+ ? activeThread.turnDiffSummaries
418+ : deriveTurnDiffSummaries(activeThread?.events ?? []),
419+ [activeThread?.events, activeThread?.turnDiffSummaries],
414420 );
415421 const turnDiffSummaryByAssistantMessageId = useMemo(() => {
416422 const byMessageId = new Map<string, TurnDiffSummary>();
@@ -420,6 +426,134 @@ export default function ChatView() { · 3 unmodified lines
420426 }
421427 return byMessageId;
422428 }, [turnDiffSummaries]);
429+ const inferredCheckpointTurnCountByTurnId = useMemo(
430+ () => inferCheckpointTurnCountByTurnId(turnDiffSummaries),
431+ [turnDiffSummaries],
432+ );
433+ const revertTurnCountByUserMessageId = useMemo(() => {
434+ const byUserMessageId = new Map<string, number>();
435+ for (let index = 0; index < timelineEntries.length; index += 1) {
436+ const entry = timelineEntries[index];
437+ if (!entry || entry.kind !== "message" || entry.message.role !== "user") {
438+ continue;
439+ }
440+
441+ for (let nextIndex = index + 1; nextIndex < timelineEntries.length; nextIndex += 1) {
442+ const nextEntry = timelineEntries[nextIndex];
443+ if (!nextEntry || nextEntry.kind !== "message") {
444+ continue;
445+ }
446+ if (nextEntry.message.role === "user") {
447+ break;
448+ }
449+ const summary = turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id);
450+ if (!summary) {
451+ continue;
452+ }
453+ const turnCount =
454+ summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId];
455+ if (typeof turnCount !== "number") {
456+ break;
457+ }
458+ byUserMessageId.set(entry.message.id, Math.max(0, turnCount - 1));
459+ break;
460+ }
461+ }
462+
463+ return byUserMessageId;
464+ }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);
465+
466+ useEffect(() => {
467+ if (!activeThreadId || turnDiffSummaries.length === 0) {
468+ return;
469+ }
470+
471+ const inferredMissingTurnCounts = turnDiffSummaries.reduce<Record<string, number>>(
472+ (acc, summary) => {
473+ if (typeof summary.checkpointTurnCount === "number") {
474+ return acc;
475+ }
476+ const inferredTurnCount = inferredCheckpointTurnCountByTurnId[summary.turnId];
477+ if (typeof inferredTurnCount !== "number") {
478+ return acc;
479+ }
480+ acc[summary.turnId] = inferredTurnCount;
481+ return acc;
482+ },
483+ {},
484+ );
485+ if (Object.keys(inferredMissingTurnCounts).length === 0) {
486+ return;
487+ }
488+
489+ dispatch({
490+ type: "SET_THREAD_TURN_CHECKPOINT_COUNTS",
491+ threadId: activeThreadId,
492+ checkpointTurnCountByTurnId: inferredMissingTurnCounts,
493+ });
494+ }, [activeThreadId, dispatch, inferredCheckpointTurnCountByTurnId, turnDiffSummaries]);
495+
496+ useEffect(() => {
497+ if (!api || !activeThreadId || !activeSessionId || turnDiffSummaries.length === 0) {
498+ return;
499+ }
500+
501+ const turnSummariesNeedingDiff = turnDiffSummaries.filter(
502+ (summary) => !summary.checkpointDiffLoaded && inferredCheckpointTurnCountByTurnId[summary.turnId],
503+ );
504+ const requestedSummaries = turnSummariesNeedingDiff.filter((summary) => {
505+ const requestKey = `${activeThreadId}:${summary.turnId}`;
506+ if (checkpointDiffRequestsRef.current.has(requestKey)) {
507+ return false;
508+ }
509+ checkpointDiffRequestsRef.current.add(requestKey);
510+ return true;
511+ });
512+ if (requestedSummaries.length === 0) {
513+ return;
514+ }
515+
516+ let cancelled = false;
517+ void Promise.all(
518+ requestedSummaries.map(async (summary) => {
519+ const turnCount = inferredCheckpointTurnCountByTurnId[summary.turnId] ?? 0;
520+ const result = await api.providers.getCheckpointDiff({
521+ sessionId: activeSessionId,
522+ fromTurnCount: Math.max(0, turnCount - 1),
523+ toTurnCount: turnCount,
524+ });
525+ return [summary.turnId, result.diff] as const;
526+ }),
527+ )
528+ .then((entries) => {
529+ if (cancelled) return;
530+ dispatch({
531+ type: "SET_THREAD_TURN_CHECKPOINT_DIFFS",
532+ threadId: activeThreadId,
533+ checkpointDiffByTurnId: Object.fromEntries(entries),
534+ });
535+ })
536+ .catch(() => {
537+ if (cancelled) return;
538+ })
539+ .finally(() => {
540+ for (const summary of requestedSummaries) {
541+ checkpointDiffRequestsRef.current.delete(`${activeThreadId}:${summary.turnId}`);
542+ }
543+ });
544+
545+ return () => {
546+ cancelled = true;
547+ };
548+ }, [
549+ activeSessionId,
550+ activeThreadId,
551+ api,
552+ dispatch,
553+ inferredCheckpointTurnCountByTurnId,
554+ turnDiffSummaries,
555+ ]);
556+
423557 const completionSummary = useMemo(() => {
424558 if (!activeThread?.latestTurnStartedAt) return null;
425559 if (!activeThread.latestTurnCompletedAt) return null;
@@ -475,16 +609,9 @@ export default function ChatView() { · 49 unmodified lines
475609 completionSummary,
476610 timelineEntries,
477611 ]);
478− const runtimeSessionConfig =
479− state.runtimeMode === "full-access"
480− ? ({
481− approvalPolicy: "never",
482− sandboxMode: "danger-full-access",
483− } as const)
484− : ({
485− approvalPolicy: "on-request",
486− sandboxMode: "workspace-write",
487− } as const);
612+ const runtimeApprovalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request";
613+ const runtimeSandboxMode =
614+ state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write";
488615 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
489616 const composerTrigger = useMemo(
490617 () => detectComposerTrigger(prompt, composerCursor),
@@ -593,12 +720,6 @@ export default function ChatView() { · 102 unmodified lines
593720 );
594721 const hasReachedTerminalLimit =
595722 (activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT;
596− const canCheckpoint =
597− Boolean(api && activeThread && (activeThread.codexThreadId ?? activeThread.session?.threadId)) &&
598− phase !== "running" &&
599− !isSending &&
600− !isConnecting &&
601− !isRevertingCheckpoint;
602723
603724 const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => {
604725 for (const image of images) {
@@ -988,8 +1109,6 @@ export default function ChatView() { · 383 unmodified lines
9881109 }, [composerMenuItems, composerMenuOpen]);
9891110
9901111 useEffect(() => {
991− setCheckpoints([]);
992− setIsLoadingCheckpoints(false);
9931112 setIsRevertingCheckpoint(false);
9941113 }, [activeThread?.id]);
9951114
@@ -1172,14 +1291,17 @@ export default function ChatView() { · 176 unmodified lines
11721291 toggleTerminalVisibility,
11731292 ]);
11741293
1175− const setThreadError = (threadId: string | null, error: string | null) => {
1176− if (!threadId) return;
1177− dispatch({
1178− type: "SET_ERROR",
1179− threadId,
1180− error,
1181− });
1182− };
1294+ const setThreadError = useCallback(
1295+ (threadId: string | null, error: string | null) => {
1296+ if (!threadId) return;
1297+ dispatch({
1298+ type: "SET_ERROR",
1299+ threadId,
1300+ error,
1301+ });
1302+ },
1303+ [dispatch],
1304+ );
11831305
11841306 const addComposerImages = (files: File[]) => {
11851307 if (!activeThreadId || files.length === 0) return;
@@ -1284,134 +1406,169 @@ export default function ChatView() { · 98 unmodified lines
12841406 focusComposer();
12851407 };
12861408
1287− const ensureSession = async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => {
1288− if (!api || !activeThread || !activeProject) return null;
1289− if (activeThread.session && activeThread.session.status !== "closed") {
1290− const sessionThreadId = activeThread.session.threadId ?? null;
1291− const continuityState: SessionContinuityState =
1292− activeThread.codexThreadId === null
1293− ? "new"
1294− : sessionThreadId === activeThread.codexThreadId
1295− ? "resumed"
1296− : "fallback_new";
1297− return {
1298− sessionId: activeThread.session.sessionId,
1299− resolvedThreadId: sessionThreadId,
1300− continuityState,
1301− } satisfies EnsuredSessionInfo;
1302− }
1303−
1304− const priorCodexThreadId = activeThread.codexThreadId;
1305− setIsConnecting(true);
1306− try {
1307− const session = await api.providers.startSession({
1308− provider: "codex",
1309− cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd,
1310− model: selectedModel || undefined,
1311− resumeThreadId: priorCodexThreadId ?? undefined,
1312− approvalPolicy: runtimeSessionConfig.approvalPolicy,
1313− sandboxMode: runtimeSessionConfig.sandboxMode,
1314− });
1315− dispatch({
1316− type: "UPDATE_SESSION",
1317− threadId: activeThread.id,
1318− session,
1319− });
1320− const resolvedThreadId = session.threadId ?? null;
1321− const continuityState: SessionContinuityState =
1322− priorCodexThreadId === null
1323− ? "new"
1324− : resolvedThreadId === priorCodexThreadId
1325− ? "resumed"
1326− : "fallback_new";
1327− return {
1328− sessionId: session.sessionId,
1329− resolvedThreadId,
1330− continuityState,
1331− };
1332− } catch (err) {
1333− dispatch({
1334− type: "SET_ERROR",
1335− threadId: activeThread.id,
1336− error: err instanceof Error ? err.message : "Failed to connect.",
1337− });
1338− return null;
1339− } finally {
1340− setIsConnecting(false);
1341− }
1342− };
1343−
1344− const loadCheckpoints = async (): Promise<ProviderCheckpoint[] | null> => {
1345− if (!api || !activeThread) return null;
1346− if (phase === "running" || isSending || isConnecting || isRevertingCheckpoint) {
1347− return null;
1348− }
1349− const hasThreadIdentity = Boolean(activeThread.codexThreadId ?? activeThread.session?.threadId);
1350− if (!hasThreadIdentity) {
1351− setCheckpoints([]);
1352− return [];
1353− }
1409+ const ensureSession = useCallback(
1410+ async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => {
1411+ if (!api || !activeThread || !activeProject) return null;
1412+ if (activeThread.session && activeThread.session.status !== "closed") {
1413+ const sessionThreadId = activeThread.session.threadId ?? null;
1414+ const continuityState: SessionContinuityState =
1415+ activeThread.codexThreadId === null
1416+ ? "new"
1417+ : sessionThreadId === activeThread.codexThreadId
1418+ ? "resumed"
1419+ : "fallback_new";
1420+ return {
1421+ sessionId: activeThread.session.sessionId,
1422+ resolvedThreadId: sessionThreadId,
1423+ continuityState,
1424+ } satisfies EnsuredSessionInfo;
1425+ }
13541426
1355− setIsLoadingCheckpoints(true);
1356− setThreadError(activeThread.id, null);
1357− try {
1358− const sessionInfo = await ensureSession();
1359− if (!sessionInfo) {
1427+ const priorCodexThreadId = activeThread.codexThreadId;
1428+ setIsConnecting(true);
1429+ try {
1430+ const session = await api.providers.startSession({
1431+ provider: "codex",
1432+ cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd,
1433+ model: selectedModel || undefined,
1434+ resumeThreadId: priorCodexThreadId ?? undefined,
1435+ approvalPolicy: runtimeApprovalPolicy,
1436+ sandboxMode: runtimeSandboxMode,
1437+ });
1438+ dispatch({
1439+ type: "UPDATE_SESSION",
1440+ threadId: activeThread.id,
1441+ session,
1442+ });
1443+ const resolvedThreadId = session.threadId ?? null;
1444+ const continuityState: SessionContinuityState =
1445+ priorCodexThreadId === null
1446+ ? "new"
1447+ : resolvedThreadId === priorCodexThreadId
1448+ ? "resumed"
1449+ : "fallback_new";
1450+ return {
1451+ sessionId: session.sessionId,
1452+ resolvedThreadId,
1453+ continuityState,
1454+ };
1455+ } catch (err) {
1456+ dispatch({
1457+ type: "SET_ERROR",
1458+ threadId: activeThread.id,
1459+ error: err instanceof Error ? err.message : "Failed to connect.",
1460+ });
13601461 return null;
1462+ } finally {
1463+ setIsConnecting(false);
13611464 }
1362− const result = await api.providers.listCheckpoints({
1363− sessionId: sessionInfo.sessionId,
1364− });
1365− setCheckpoints(result.checkpoints);
1366− return result.checkpoints;
1367− } catch (err) {
1368− setThreadError(
1369− activeThread.id,
1370− err instanceof Error ? err.message : "Failed to load checkpoints.",
1371− );
1372− return null;
1373− } finally {
1374− setIsLoadingCheckpoints(false);
1375− }
1376− };
1465+ },
1466+ [activeProject, activeThread, api, dispatch, runtimeApprovalPolicy, runtimeSandboxMode, selectedModel],
1467+ );
13771468
1378− const onRevertToCheckpoint = async (checkpoint: ProviderCheckpoint) => {
1379− if (!api || !activeThread || checkpoint.isCurrent || isRevertingCheckpoint) {
1380− return;
1381− }
1382− if (phase === "running" || isSending || isConnecting) {
1383− setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
1469+ useEffect(() => {
1470+ if (!api || !activeThreadId || activeSessionId) return;
1471+ if (phase === "running" || isSending || isConnecting || isRevertingCheckpoint) return;
1472+ const hasThreadIdentity = Boolean(activeThreadRuntimeId);
1473+ if (!hasThreadIdentity) return;
1474+ const selectedDiffTurnId =
1475+ state.diffOpen && state.diffThreadId === activeThreadId ? state.diffTurnId : null;
1476+ const selectedDiffTurn =
1477+ turnDiffSummaries.find((summary) => summary.turnId === selectedDiffTurnId) ?? turnDiffSummaries[0];
1478+ const selectedTurnMissingPatchBody = Boolean(
1479+ selectedDiffTurn &&
1480+ !selectedDiffTurn.unifiedDiff &&
1481+ selectedDiffTurn.files.every((file) => !file.diff),
1482+ );
1483+ const hasPendingDiffHydration =
1484+ turnDiffSummaries.some((summary) => !summary.checkpointDiffLoaded) || selectedTurnMissingPatchBody;
1485+ if (!hasPendingDiffHydration) return;
1486+
1487+ const requestKey = `${activeThreadId}:${activeThreadRuntimeId ?? "none"}`;
1488+ if (checkpointHydrationSessionRequestRef.current.has(requestKey)) {
13841489 return;
13851490 }
1491+ checkpointHydrationSessionRequestRef.current.add(requestKey);
1492+ setThreadError(activeThreadId, null);
1493+ void ensureSession().finally(() => {
1494+ checkpointHydrationSessionRequestRef.current.delete(requestKey);
1495+ });
1496+ }, [
1497+ activeThreadId,
1498+ activeThreadRuntimeId,
1499+ activeSessionId,
1500+ api,
1501+ ensureSession,
1502+ isConnecting,
1503+ isRevertingCheckpoint,
1504+ isSending,
1505+ phase,
1506+ setThreadError,
1507+ state.diffOpen,
1508+ state.diffThreadId,
1509+ state.diffTurnId,
1510+ turnDiffSummaries,
1511+ ]);
13861512
1387− setIsRevertingCheckpoint(true);
1388− setThreadError(activeThread.id, null);
1389− try {
1390− const sessionInfo = await ensureSession();
1391− if (!sessionInfo) {
1513+ const onRevertToTurnCount = useCallback(
1514+ async (turnCount: number) => {
1515+ if (!api || !activeThread || isRevertingCheckpoint) {
1516+ return;
1517+ }
1518+ if (phase === "running" || isSending || isConnecting) {
1519+ setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
13921520 return;
13931521 }
1394− const result = await api.providers.revertToCheckpoint({
1395− sessionId: sessionInfo.sessionId,
1396− turnCount: checkpoint.turnCount,
1397− });
1398− dispatch({
1399− type: "REVERT_TO_CHECKPOINT",
1400− threadId: activeThread.id,
1401− sessionId: sessionInfo.sessionId,
1402− threadRuntimeId: result.threadId,
1403− messageCount: result.messageCount,
1404− });
1405− setCheckpoints(result.checkpoints);
1406− } catch (err) {
1407− setThreadError(
1408− activeThread.id,
1409− err instanceof Error ? err.message : "Failed to revert checkpoint.",
1410− );
1411− } finally {
1412− setIsRevertingCheckpoint(false);
1413− }
1414− };
1522+
1523+ setIsRevertingCheckpoint(true);
1524+ setThreadError(activeThread.id, null);
1525+ try {
1526+ const sessionInfo = await ensureSession();
1527+ if (!sessionInfo) {
1528+ return;
1529+ }
1530+ const result = await api.providers.revertToCheckpoint({
1531+ sessionId: sessionInfo.sessionId,
1532+ turnCount,
1533+ });
1534+ dispatch({
1535+ type: "REVERT_TO_CHECKPOINT",
1536+ threadId: activeThread.id,
1537+ sessionId: sessionInfo.sessionId,
1538+ threadRuntimeId: result.threadId,
1539+ turnCount: result.turnCount,
1540+ messageCount: result.messageCount,
1541+ });
1542+ dispatch({
1543+ type: "SET_THREAD_TURN_CHECKPOINT_COUNTS",
1544+ threadId: activeThread.id,
1545+ checkpointTurnCountByTurnId: Object.fromEntries(
1546+ result.checkpoints
1547+ .filter((entry) => entry.turnCount > 0)
1548+ .map((entry) => [entry.id, entry.turnCount] as const),
1549+ ),
1550+ });
1551+ } catch (err) {
1552+ setThreadError(
1553+ activeThread.id,
1554+ err instanceof Error ? err.message : "Failed to revert thread state.",
1555+ );
1556+ } finally {
1557+ setIsRevertingCheckpoint(false);
1558+ }
1559+ },
1560+ [
1561+ activeThread,
1562+ api,
1563+ dispatch,
1564+ ensureSession,
1565+ isConnecting,
1566+ isRevertingCheckpoint,
1567+ isSending,
1568+ phase,
1569+ setThreadError,
1570+ ],
1571+ );
14151572
14161573 const onSend = async (e: FormEvent) => {
14171574 e.preventDefault();
@@ -1704,8 +1861,7 @@ export default function ChatView() { · 286 unmodified lines
17041861 return;
17051862 }
17061863
1707− const latestSummary = turnDiffSummaries[0];
1708− if (!activeThread || !latestSummary) {
1864+ if (!activeThread) {
17091865 dispatch({ type: "TOGGLE_DIFF" });
17101866 return;
17111867 }
@@ -1713,10 +1869,8 @@ export default function ChatView() { · 1 unmodified line
17131869 dispatch({
17141870 type: "OPEN_DIFF",
17151871 threadId: activeThread.id,
1716− turnId: latestSummary.turnId,
1717− ...(latestSummary.files[0]?.path ? { filePath: latestSummary.files[0].path } : {}),
17181872 });
1719− }, [activeThread, dispatch, state.diffOpen, turnDiffSummaries]);
1873+ }, [activeThread, dispatch, state.diffOpen]);
17201874 const onOpenTurnDiff = useCallback(
17211875 (turnId: string, filePath?: string) => {
17221876 if (!activeThread) return;
@@ -1729,6 +1883,16 @@ export default function ChatView() { · 6 unmodified lines
17291883 },
17301884 [activeThread, dispatch],
17311885 );
1886+ const onRevertUserMessage = useCallback(
1887+ (messageId: string) => {
1888+ const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);
1889+ if (typeof targetTurnCount !== "number") {
1890+ return;
1891+ }
1892+ void onRevertToTurnCount(targetTurnCount);
1893+ },
1894+ [onRevertToTurnCount, revertTurnCountByUserMessageId],
1895+ );
17321896
17331897 // Empty state: no active thread
17341898 if (!activeThread) {
@@ -1771,14 +1935,6 @@ export default function ChatView() { · 36 unmodified lines
17711935 onAddProjectScript={saveProjectScript}
17721936 onUpdateProjectScript={updateProjectScript}
17731937 onToggleDiff={onToggleDiff}
1774− checkpoints={checkpoints}
1775− canCheckpoint={canCheckpoint}
1776− isLoadingCheckpoints={isLoadingCheckpoints}
1777− isRevertingCheckpoint={isRevertingCheckpoint}
1778− onOpenCheckpoints={() => {
1779− void loadCheckpoints();
1780− }}
1781− onRevertToCheckpoint={onRevertToCheckpoint}
17821938 />
17831939 </header>
17841940
@@ -1808,6 +1964,9 @@ export default function ChatView() { · 23 unmodified lines
18081964 expandedWorkGroups={expandedWorkGroups}
18091965 onToggleWorkGroup={onToggleWorkGroup}
18101966 onOpenTurnDiff={onOpenTurnDiff}
1967+ revertTurnCountByUserMessageId={revertTurnCountByUserMessageId}
1968+ onRevertUserMessage={onRevertUserMessage}
1969+ isRevertingCheckpoint={isRevertingCheckpoint}
18111970 onImageExpand={onExpandTimelineImage}
18121971 messagesEndRef={messagesEndRef}
18131972 />
@@ -2114,12 +2273,6 @@ interface ChatHeaderProps { · 300 unmodified lines
21142273 onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>;
21152274 onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>;
21162275 onToggleDiff: () => void;
2117− checkpoints: ProviderCheckpoint[];
2118− canCheckpoint: boolean;
2119− isLoadingCheckpoints: boolean;
2120− isRevertingCheckpoint: boolean;
2121− onOpenCheckpoints: () => void;
2122− onRevertToCheckpoint: (checkpoint: ProviderCheckpoint) => void;
21232276 }
21242277
21252278 const ChatHeader = memo(function ChatHeader({
@@ -2135,12 +2288,6 @@ const ChatHeader = memo(function ChatHeader({ · 9 unmodified lines
21352288 onAddProjectScript,
21362289 onUpdateProjectScript,
21372290 onToggleDiff,
2138− checkpoints,
2139− canCheckpoint,
2140− isLoadingCheckpoints,
2141− isRevertingCheckpoint,
2142− onOpenCheckpoints,
2143− onRevertToCheckpoint,
21442291 }: ChatHeaderProps) {
21452292 return (
21462293 <>
@@ -2163,56 +2310,14 @@ const ChatHeader = memo(function ChatHeader({ · 16 unmodified lines
21632310 )}
21642311 {activeProjectName && <OpenInPicker keybindings={keybindings} />}
21652312 {activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />}
2166− <Menu>
2167− <MenuTrigger
2168− render={
2169− <Button
2170− size="xs"
2171− variant="ghost"
2172− className="text-muted-foreground/70 hover:text-foreground/80"
2173− disabled={!canCheckpoint || isRevertingCheckpoint}
2174− onClick={onOpenCheckpoints}
2175− >
2176− {isRevertingCheckpoint
2177− ? "Reverting..."
2178− : isLoadingCheckpoints
2179− ? "Loading..."
2180− : "Checkpoints"}
2181− </Button>
2182− }
2183− />
2184− <MenuPopup align="end" className="w-72">
2185− {!canCheckpoint ? (
2186− <MenuItem disabled>Send at least one message to create checkpoints.</MenuItem>
2187− ) : checkpoints.length === 0 ? (
2188− <MenuItem disabled>
2189− {isLoadingCheckpoints ? "Loading checkpoints..." : "No checkpoints available."}
2190− </MenuItem>
2191− ) : (
2192− checkpoints.map((checkpoint) => (
2193− <MenuItem
2194− key={checkpoint.id}
2195− disabled={checkpoint.isCurrent || isRevertingCheckpoint}
2196− onClick={() => onRevertToCheckpoint(checkpoint)}
2197− >
2198− {checkpoint.label}
2199− {checkpoint.preview ? `: ${checkpoint.preview}` : ""}
2200− {checkpoint.isCurrent ? " (Current)" : ""}
2201− </MenuItem>
2202− ))
2203− )}
2204− </MenuPopup>
2205− </Menu>
22062313 <Button
2207− size="xs"
2208− variant="ghost"
2209− className={cn(
2210− "text-muted-foreground/70 hover:text-foreground/80",
2211− diffOpen && "bg-accent text-accent-foreground",
2212− )}
2314+ aria-label="Toggle diff panel"
2315+ size="icon-xs"
2316+ variant="outline"
2317+ className={cn(diffOpen && "bg-accent text-accent-foreground")}
22132318 onClick={onToggleDiff}
22142319 >
2215− Diff
2320+ <DiffIcon />
22162321 </Button>
22172322 </div>
22182323 </>
@@ -2317,6 +2422,9 @@ interface MessagesTimelineProps { · 98 unmodified lines
23172422 expandedWorkGroups: Record<string, boolean>;
23182423 onToggleWorkGroup: (groupId: string) => void;
23192424 onOpenTurnDiff: (turnId: string, filePath?: string) => void;
2425+ revertTurnCountByUserMessageId: Map<string, number>;
2426+ onRevertUserMessage: (messageId: string) => void;
2427+ isRevertingCheckpoint: boolean;
23202428 onImageExpand: (image: ExpandedImagePreview) => void;
23212429 messagesEndRef: RefObject<HTMLDivElement | null>;
23222430 }
@@ -2333,6 +2441,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 10 unmodified lines
23332441 expandedWorkGroups,
23342442 onToggleWorkGroup,
23352443 onOpenTurnDiff,
2444+ revertTurnCountByUserMessageId,
2445+ onRevertUserMessage,
2446+ isRevertingCheckpoint,
23362447 onImageExpand,
23372448 messagesEndRef,
23382449 }: MessagesTimelineProps) {
@@ -2433,6 +2544,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 94 unmodified lines
24332544
24342545 if (timelineEntry.message.role === "user") {
24352546 const userImages = timelineEntry.message.attachments ?? [];
2547+ const canRevertAgentWork = revertTurnCountByUserMessageId.has(timelineEntry.message.id);
24362548 return (
24372549 <Fragment key={timelineEntry.id}>
24382550 <div className="flex justify-end">
@@ -2467,9 +2579,22 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 28 unmodified lines
24672579 {timelineEntry.message.text}
24682580 </pre>
24692581 )}
2470− <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30">
2471− {formatTimestamp(timelineEntry.message.createdAt)}
2472− </p>
2582+ <div className="mt-1.5 flex items-center justify-end gap-2">
2583+ {canRevertAgentWork && (
2584+ <Button
2585+ type="button"
2586+ size="xs"
2587+ variant="outline"
2588+ disabled={isRevertingCheckpoint || isWorking}
2589+ onClick={() => onRevertUserMessage(timelineEntry.message.id)}
2590+ >
2591+ {isRevertingCheckpoint ? "Reverting..." : "Revert agent work"}
2592+ </Button>
2593+ )}
2594+ <p className="text-right text-[10px] text-muted-foreground/30">
2595+ {formatTimestamp(timelineEntry.message.createdAt)}
2596+ </p>
2597+ </div>
24732598 </div>
24742599 </div>
24752600 </Fragment>
@@ -2497,11 +2622,45 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 21 unmodified lines
24972622 {(() => {
24982623 const turnSummary = turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id);
24992624 if (!turnSummary) return null;
2625+ const isCheckpointDiffLoading =
2626+ !turnSummary.checkpointDiffLoaded && turnSummary.files.length === 0;
2627+ const summaryStat = turnSummary.unifiedDiff
2628+ ? countDiffStat(turnSummary.unifiedDiff)
2629+ : turnSummary.files.reduce(
2630+ (acc, file) => {
2631+ const next =
2632+ typeof file.additions === "number" && typeof file.deletions === "number"
2633+ ? { additions: file.additions, deletions: file.deletions }
2634+ : file.diff
2635+ ? countDiffStat(file.diff)
2636+ : null;
2637+ if (!next) {
2638+ return acc;
2639+ }
2640+ return {
2641+ additions: acc.additions + next.additions,
2642+ deletions: acc.deletions + next.deletions,
2643+ };
2644+ },
2645+ { additions: 0, deletions: 0 },
2646+ );
2647+ const changedFileCountLabel = isCheckpointDiffLoading
2648+ ? "..."
2649+ : String(turnSummary.files.length);
25002650 return (
25012651 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
25022652 <div className="mb-1.5 flex items-center justify-between gap-2">
25032653 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
2504− Changed files ({turnSummary.files.length})
2654+ <span>Changed files ({changedFileCountLabel})</span>
2655+ {!isCheckpointDiffLoading &&
2656+ (summaryStat.additions > 0 || summaryStat.deletions > 0) && (
2657+ <>
2658+ <span className="mx-1">•</span>
2659+ <span className="text-success">+{summaryStat.additions}</span>
2660+ <span className="mx-0.5 text-muted-foreground/70">/</span>
2661+ <span className="text-destructive">-{summaryStat.deletions}</span>
2662+ </>
2663+ )}
25052664 </p>
25062665 <Button
25072666 type="button"
@@ -2512,18 +2671,48 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 4 unmodified lines
25122671 View diff
25132672 </Button>
25142673 </div>
2515− <div className="flex flex-wrap gap-1.5">
2516− {turnSummary.files.map((file) => (
2517− <button
2518− key={`${turnSummary.turnId}:${file.path}`}
2519− type="button"
2520− className="rounded-md border border-border/70 bg-background/70 px-2 py-1 font-mono text-[11px] text-muted-foreground/80 transition-colors hover:border-border hover:text-foreground/90"
2521− onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)}
2522− >
2523− {file.path}
2524− </button>
2525− ))}
2526− </div>
2674+ {isCheckpointDiffLoading && (
2675+ <p className="mb-1.5 text-[11px] text-muted-foreground/70">
2676+ Loading checkpoint diff...
2677+ </p>
2678+ )}
2679+ {turnSummary.files.length > 0 ? (
2680+ <div className="flex flex-wrap gap-1.5">
2681+ {turnSummary.files.map((file) => (
2682+ <button
2683+ key={`${turnSummary.turnId}:${file.path}`}
2684+ type="button"
2685+ className="rounded-md border border-border/70 bg-background/70 px-2 py-1 font-mono text-[11px] text-muted-foreground/80 transition-colors hover:border-border hover:text-foreground/90"
2686+ onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)}
2687+ >
2688+ {(() => {
2689+ const stat =
2690+ typeof file.additions === "number" &&
2691+ typeof file.deletions === "number"
2692+ ? { additions: file.additions, deletions: file.deletions }
2693+ : file.diff
2694+ ? countDiffStat(file.diff)
2695+ : null;
2696+ if (!stat) {
2697+ return file.path;
2698+ }
2699+ return (
2700+ <>
2701+ <span>{file.path}</span>
2702+ <span className="ml-1 text-muted-foreground/70">(</span>
2703+ <span className="text-success">+{stat.additions}</span>
2704+ <span className="mx-0.5 text-muted-foreground/70">/</span>
2705+ <span className="text-destructive">-{stat.deletions}</span>
2706+ <span className="text-muted-foreground/70">)</span>
2707+ </>
2708+ );
2709+ })()}
2710+ </button>
2711+ ))}
2712+ </div>
2713+ ) : !isCheckpointDiffLoading ? (
2714+ <p className="text-[11px] text-muted-foreground/70">No changed files.</p>
2715+ ) : null}
25272716 </div>
25282717 );
25292718 })()}
apps/web/src/components/DiffPanel.tsx+475−90
@@ -1,62 +1,473 @@
1−import { PatchDiff } from "@pierre/diffs/react";
2−import { XIcon } from "lucide-react";
3−import { useMemo } from "react";
1+import { parsePatchFiles } from "@pierre/diffs";
2+import { FileDiff, type FileDiffMetadata } from "@pierre/diffs/react";
3+import { Columns2Icon, Rows3Icon, XIcon } from "lucide-react";
4+import { useEffect, useMemo, useRef, useState } from "react";
45 import { isElectron } from "../env";
5−import { deriveTurnDiffSummaries, formatTimestamp } from "../session-logic";
6+import { useNativeApi } from "../hooks/useNativeApi";
7+import { useTheme } from "../hooks/useTheme";
8+import {
9+ deriveTurnDiffSummaries,
10+ formatTimestamp,
11+ inferCheckpointTurnCountByTurnId,
12+} from "../session-logic";
613 import { useStore } from "../store";
714 import { Button } from "./ui/button";
15+import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
816 import { cn } from "~/lib/utils";
917
10−export default function DiffPanel() {
18+type DiffRenderMode = "stacked" | "split";
19+
20+type RenderablePatch =
21+ | {
22+ kind: "files";
23+ files: FileDiffMetadata[];
24+ }
25+ | {
26+ kind: "raw";
27+ text: string;
28+ reason: string;
29+ };
30+
31+function getRenderablePatch(patch: string | undefined): RenderablePatch | null {
32+ if (!patch) return null;
33+ const normalizedPatch = patch.trim();
34+ if (normalizedPatch.length === 0) return null;
35+
36+ try {
37+ const parsedPatches = parsePatchFiles(normalizedPatch, "diff-panel");
38+ const files = parsedPatches.flatMap((parsedPatch) => parsedPatch.files);
39+ if (files.length > 0) {
40+ return { kind: "files", files };
41+ }
42+
43+ return {
44+ kind: "raw",
45+ text: normalizedPatch,
46+ reason: "Unsupported diff format. Showing raw patch.",
47+ };
48+ } catch {
49+ return {
50+ kind: "raw",
51+ text: normalizedPatch,
52+ reason: "Failed to parse patch. Showing raw patch.",
53+ };
54+ }
55+}
56+
57+function resolveFileDiffPath(fileDiff: FileDiffMetadata): string {
58+ const raw = fileDiff.name ?? fileDiff.prevName ?? "";
59+ if (raw.startsWith("a/") || raw.startsWith("b/")) {
60+ return raw.slice(2);
61+ }
62+ return raw;
63+}
64+
65+function buildCheckpointDiffKey(
66+ sessionId: string,
67+ fromTurnCount: number,
68+ toTurnCount: number,
69+): string {
70+ return `${sessionId}:${fromTurnCount}-${toTurnCount}`;
71+}
72+
73+interface DiffPanelProps {
74+ mode?: "inline" | "sheet";
75+}
76+
77+export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
78+ const api = useNativeApi();
79+ const { resolvedTheme } = useTheme();
1180 const { state, dispatch } = useStore();
81+ const [checkpointDiffByKey, setCheckpointDiffByKey] = useState<Record<string, string>>({});
82+ const [isLoadingCheckpointDiff, setIsLoadingCheckpointDiff] = useState(false);
83+ const [checkpointDiffError, setCheckpointDiffError] = useState<string | null>(null);
84+ const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
85+ const patchViewportRef = useRef<HTMLDivElement>(null);
1286 const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId);
1387 const turnDiffSummaries = useMemo(
14− () => deriveTurnDiffSummaries(activeThread?.events ?? []),
15− [activeThread?.events],
88+ () =>
89+ activeThread?.turnDiffSummaries.length
90+ ? activeThread.turnDiffSummaries
91+ : deriveTurnDiffSummaries(activeThread?.events ?? []),
92+ [activeThread?.events, activeThread?.turnDiffSummaries],
1693 );
94+ const inferredCheckpointTurnCountByTurnId = useMemo(
95+ () => inferCheckpointTurnCountByTurnId(turnDiffSummaries),
96+ [turnDiffSummaries],
97+ );
98+
99+ useEffect(() => {
100+ setCheckpointDiffByKey({});
101+ setCheckpointDiffError(null);
102+ }, [activeThread?.id]);
17103
18104 const canApplyStoredTarget = Boolean(activeThread && state.diffThreadId === activeThread.id);
19105 const selectedTurnId = canApplyStoredTarget ? state.diffTurnId : null;
20− const selectedFilePath = canApplyStoredTarget ? state.diffFilePath : null;
106+ const selectedFilePath =
107+ canApplyStoredTarget && selectedTurnId !== null ? state.diffFilePath : null;
21108 const selectedTurn =
22− turnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? turnDiffSummaries[0];
23− const selectedFile =
24− selectedTurn?.files.find((file) => file.path === selectedFilePath) ?? selectedTurn?.files[0];
25− const selectedPatch = selectedFile?.diff ?? selectedTurn?.unifiedDiff;
109+ selectedTurnId === null
110+ ? undefined
111+ : (turnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ??
112+ turnDiffSummaries[0]);
113+ const selectedCheckpointTurnCount =
114+ selectedTurn &&
115+ (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]);
116+ const selectedCheckpointRange = useMemo(
117+ () =>
118+ typeof selectedCheckpointTurnCount === "number"
119+ ? {
120+ fromTurnCount: Math.max(0, selectedCheckpointTurnCount - 1),
121+ toTurnCount: selectedCheckpointTurnCount,
122+ }
123+ : null,
124+ [selectedCheckpointTurnCount],
125+ );
126+ const conversationCheckpointTurnCount = useMemo(() => {
127+ const turnCounts = turnDiffSummaries
128+ .map(
129+ (summary) =>
130+ summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId],
131+ )
132+ .filter((value): value is number => typeof value === "number");
133+ if (turnCounts.length === 0) {
134+ return undefined;
135+ }
136+ const latest = Math.max(...turnCounts);
137+ return latest > 0 ? latest : undefined;
138+ }, [inferredCheckpointTurnCountByTurnId, turnDiffSummaries]);
139+ const conversationCheckpointRange = useMemo(
140+ () =>
141+ !selectedTurn && typeof conversationCheckpointTurnCount === "number"
142+ ? {
143+ fromTurnCount: 0,
144+ toTurnCount: conversationCheckpointTurnCount,
145+ }
146+ : null,
147+ [conversationCheckpointTurnCount, selectedTurn],
148+ );
149+ const selectedCheckpointDiffKey =
150+ activeThread?.session?.sessionId && selectedCheckpointRange
151+ ? buildCheckpointDiffKey(
152+ activeThread.session.sessionId,
153+ selectedCheckpointRange.fromTurnCount,
154+ selectedCheckpointRange.toTurnCount,
155+ )
156+ : null;
157+ const conversationCheckpointDiffKey =
158+ activeThread?.session?.sessionId && conversationCheckpointRange
159+ ? buildCheckpointDiffKey(
160+ activeThread.session.sessionId,
161+ conversationCheckpointRange.fromTurnCount,
162+ conversationCheckpointRange.toTurnCount,
163+ )
164+ : null;
165+ const selectedTurnCheckpointDiff =
166+ selectedCheckpointDiffKey === null ? undefined : checkpointDiffByKey[selectedCheckpointDiffKey];
167+ const conversationCheckpointDiff =
168+ conversationCheckpointDiffKey === null
169+ ? undefined
170+ : checkpointDiffByKey[conversationCheckpointDiffKey];
171+ const selectedPatch = useMemo(() => {
172+ const patchForSummary = (summary: (typeof turnDiffSummaries)[number]): string | undefined => {
173+ const checkpointTurnCount =
174+ summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId];
175+ if (activeThread?.session?.sessionId && typeof checkpointTurnCount === "number") {
176+ const checkpointPatch =
177+ checkpointDiffByKey[
178+ buildCheckpointDiffKey(
179+ activeThread.session.sessionId,
180+ Math.max(0, checkpointTurnCount - 1),
181+ checkpointTurnCount,
182+ )
183+ ];
184+ if (checkpointPatch) {
185+ return checkpointPatch;
186+ }
187+ }
188+ if (summary.unifiedDiff) {
189+ return summary.unifiedDiff;
190+ }
191+ const filePatches = summary.files
192+ .map((file) => file.diff?.trim())
193+ .filter((patch): patch is string => Boolean(patch));
194+ if (filePatches.length === 0) {
195+ return undefined;
196+ }
197+ return filePatches.join("\n\n");
198+ };
199+
200+ if (selectedTurn) {
201+ return selectedTurnCheckpointDiff ?? patchForSummary(selectedTurn);
202+ }
203+
204+ if (conversationCheckpointDiff) {
205+ return conversationCheckpointDiff;
206+ }
207+
208+ // Fallback when a conversation checkpoint diff isn't available yet:
209+ // keep one patch per file path (latest change wins) so files aren't duplicated.
210+ const latestPatchByPath = new Map<string, string>();
211+ for (const summary of turnDiffSummaries) {
212+ for (const file of summary.files) {
213+ if (latestPatchByPath.has(file.path)) {
214+ continue;
215+ }
216+ const patch = file.diff?.trim();
217+ if (!patch) {
218+ continue;
219+ }
220+ latestPatchByPath.set(file.path, patch);
221+ }
222+ }
223+ if (latestPatchByPath.size > 0) {
224+ return Array.from(latestPatchByPath.entries())
225+ .toSorted(([leftPath], [rightPath]) =>
226+ leftPath.localeCompare(rightPath, undefined, { numeric: true, sensitivity: "base" }),
227+ )
228+ .map(([, patch]) => patch)
229+ .join("\n\n");
230+ }
231+
232+ const patches = turnDiffSummaries
233+ .toReversed()
234+ .map((summary) => patchForSummary(summary)?.trim())
235+ .filter((patch): patch is string => Boolean(patch));
236+ if (patches.length === 0) {
237+ return undefined;
238+ }
239+ return patches.join("\n\n");
240+ }, [
241+ activeThread?.session?.sessionId,
242+ checkpointDiffByKey,
243+ conversationCheckpointDiff,
244+ inferredCheckpointTurnCountByTurnId,
245+ selectedTurn,
246+ selectedTurnCheckpointDiff,
247+ turnDiffSummaries,
248+ ]);
249+ const renderablePatch = useMemo(() => getRenderablePatch(selectedPatch), [selectedPatch]);
250+ const renderableFiles = useMemo(() => {
251+ if (!renderablePatch || renderablePatch.kind !== "files") {
252+ return [];
253+ }
254+ return renderablePatch.files.toSorted((left, right) =>
255+ resolveFileDiffPath(left).localeCompare(resolveFileDiffPath(right), undefined, {
256+ numeric: true,
257+ sensitivity: "base",
258+ }),
259+ );
260+ }, [renderablePatch]);
261+
262+ useEffect(() => {
263+ const checkpointFetchRange = selectedTurn
264+ ? selectedCheckpointRange
265+ : conversationCheckpointRange;
266+ const checkpointFetchKey = selectedTurn
267+ ? selectedCheckpointDiffKey
268+ : conversationCheckpointDiffKey;
269+ if (!api || !activeThread?.session?.sessionId || !checkpointFetchRange || !checkpointFetchKey) {
270+ return;
271+ }
272+ if (checkpointDiffByKey[checkpointFetchKey] !== undefined) {
273+ return;
274+ }
275+
276+ let cancelled = false;
277+ setIsLoadingCheckpointDiff(true);
278+ setCheckpointDiffError(null);
279+ void api.providers
280+ .getCheckpointDiff({
281+ sessionId: activeThread.session.sessionId,
282+ fromTurnCount: checkpointFetchRange.fromTurnCount,
283+ toTurnCount: checkpointFetchRange.toTurnCount,
284+ })
285+ .then((result) => {
286+ if (cancelled) return;
287+ setCheckpointDiffByKey((previous) => ({
288+ ...previous,
289+ [checkpointFetchKey]: result.diff,
290+ }));
291+ if (selectedTurn) {
292+ dispatch({
293+ type: "SET_THREAD_TURN_CHECKPOINT_DIFFS",
294+ threadId: activeThread.id,
295+ checkpointDiffByTurnId: {
296+ [selectedTurn.turnId]: result.diff,
297+ },
298+ });
299+ }
300+ })
301+ .catch((error) => {
302+ if (cancelled) return;
303+ setCheckpointDiffError(
304+ error instanceof Error ? error.message : "Failed to load checkpoint diff.",
305+ );
306+ })
307+ .finally(() => {
308+ if (cancelled) return;
309+ setIsLoadingCheckpointDiff(false);
310+ });
311+
312+ return () => {
313+ cancelled = true;
314+ };
315+ }, [
316+ activeThread?.id,
317+ activeThread?.session?.sessionId,
318+ api,
319+ checkpointDiffByKey,
320+ conversationCheckpointDiffKey,
321+ conversationCheckpointRange,
322+ dispatch,
323+ selectedCheckpointDiffKey,
324+ selectedCheckpointRange,
325+ selectedTurn,
326+ ]);
327+
328+ useEffect(() => {
329+ if (!selectedFilePath || !patchViewportRef.current) {
330+ return;
331+ }
332+ const target = Array.from(
333+ patchViewportRef.current.querySelectorAll<HTMLElement>("[data-diff-file-path]"),
334+ ).find((element) => element.dataset.diffFilePath === selectedFilePath);
335+ target?.scrollIntoView({ block: "nearest" });
336+ }, [selectedFilePath, renderableFiles]);
26337
27338 const selectTurn = (turnId: string) => {
28339 if (!activeThread) return;
29− const turn = turnDiffSummaries.find((summary) => summary.turnId === turnId);
30340 dispatch({
31341 type: "SET_DIFF_TARGET",
32342 threadId: activeThread.id,
33343 turnId,
34− ...(turn?.files[0]?.path ? { filePath: turn.files[0].path } : {}),
35344 });
36345 };
37−
38− const selectFile = (filePath: string) => {
39− if (!activeThread || !selectedTurn) return;
346+ const selectWholeConversation = () => {
347+ if (!activeThread) return;
40348 dispatch({
41349 type: "SET_DIFF_TARGET",
42350 threadId: activeThread.id,
43− turnId: selectedTurn.turnId,
44− filePath,
45351 });
46352 };
47353
354+ const shouldUseDragRegion = isElectron && mode === "inline";
355+
48356 return (
49− <aside className="flex h-full w-[560px] shrink-0 flex-col border-l border-border bg-card">
357+ <aside
358+ className={cn(
359+ "flex h-full min-w-0 flex-col bg-card",
360+ mode === "inline"
361+ ? "w-[42vw] min-w-[360px] max-w-[560px] shrink-0 border-l border-border"
362+ : "w-full",
363+ )}
364+ >
50365 <div
51366 className={cn(
52367 "flex items-center justify-between border-b border-border px-4",
53− isElectron ? "drag-region h-[52px]" : "py-3",
368+ shouldUseDragRegion ? "drag-region h-[52px]" : "py-3",
54369 )}
55370 >
56− <h3 className="text-xs font-medium text-foreground">Turn diffs</h3>
57− <Button type="button" size="icon-xs" variant="ghost" onClick={() => dispatch({ type: "CLOSE_DIFF" })}>
58− <XIcon />
59− </Button>
371+ <div className="flex gap-1.5 overflow-x-auto pb-0.5">
372+ <button type="button" className="shrink-0 rounded-md" onClick={selectWholeConversation}>
373+ <div
374+ className={cn(
375+ "rounded-md border px-2 py-1 text-left transition-colors",
376+ selectedTurnId === null
377+ ? "border-border bg-accent text-accent-foreground"
378+ : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",
379+ )}
380+ >
381+ <div className="text-[10px] font-medium">All turns</div>
382+ <div className="text-[8px] opacity-70">Conversation</div>
383+ </div>
384+ </button>
385+ {turnDiffSummaries.map((summary, index) => (
386+ <button
387+ key={summary.turnId}
388+ type="button"
389+ className="shrink-0 rounded-md"
390+ onClick={() => selectTurn(summary.turnId)}
391+ title={summary.turnId}
392+ >
393+ <div
394+ className={cn(
395+ "rounded-md border px-2 py-1 text-left transition-colors",
396+ summary.turnId === selectedTurn?.turnId
397+ ? "border-border bg-accent text-accent-foreground"
398+ : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",
399+ )}
400+ >
401+ <div className="text-[10px] font-medium">
402+ Turn {turnDiffSummaries.length - index}
403+ </div>
404+ <div className="text-[8px] opacity-70">{formatTimestamp(summary.completedAt)}</div>
405+ </div>
406+ </button>
407+ ))}
408+ </div>
409+ <div className="flex items-center gap-1">
410+ <Popover>
411+ <PopoverTrigger
412+ openOnHover
413+ render={
414+ <Button
415+ aria-label="Stacked diff view"
416+ aria-pressed={diffRenderMode === "stacked"}
417+ size="icon-xs"
418+ variant="outline"
419+ className={cn(diffRenderMode === "stacked" && "bg-accent text-accent-foreground")}
420+ onClick={() => setDiffRenderMode("stacked")}
421+ />
422+ }
423+ >
424+ <Rows3Icon />
425+ </PopoverTrigger>
426+ <PopoverPopup
427+ tooltipStyle
428+ side="bottom"
429+ sideOffset={6}
430+ align="center"
431+ className="pointer-events-none select-none"
432+ >
433+ Stacked
434+ </PopoverPopup>
435+ </Popover>
436+ <Popover>
437+ <PopoverTrigger
438+ openOnHover
439+ render={
440+ <Button
441+ aria-label="Split diff view"
442+ aria-pressed={diffRenderMode === "split"}
443+ size="icon-xs"
444+ variant="outline"
445+ className={cn(diffRenderMode === "split" && "bg-accent text-accent-foreground")}
446+ onClick={() => setDiffRenderMode("split")}
447+ />
448+ }
449+ >
450+ <Columns2Icon />
451+ </PopoverTrigger>
452+ <PopoverPopup
453+ tooltipStyle
454+ side="bottom"
455+ sideOffset={6}
456+ align="center"
457+ className="pointer-events-none select-none"
458+ >
459+ Split
460+ </PopoverPopup>
461+ </Popover>
462+ <Button
463+ type="button"
464+ size="icon-xs"
465+ variant="ghost"
466+ onClick={() => dispatch({ type: "CLOSE_DIFF" })}
467+ >
468+ <XIcon />
469+ </Button>
470+ </div>
60471 </div>
61472
62473 {!activeThread ? (
@@ -65,78 +476,52 @@ export default function DiffPanel() { · 2 unmodified lines
65476 </div>
66477 ) : turnDiffSummaries.length === 0 ? (
67478 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
68− No completed turn file changes yet.
479+ No completed turns yet.
69480 </div>
70481 ) : (
71482 <>
72− <div className="border-b border-border px-3 py-2.5">
73− <p className="mb-1.5 text-[10px] uppercase tracking-[0.12em] text-muted-foreground/60">
74− Turns
75− </p>
76− <div className="flex flex-wrap gap-1.5">
77− {turnDiffSummaries.map((summary, index) => (
78− <button
79− key={summary.turnId}
80− type="button"
81− className={cn(
82− "rounded-md border px-2 py-1 text-left transition-colors",
83− summary.turnId === selectedTurn?.turnId
84− ? "border-border bg-accent text-accent-foreground"
85− : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",
86− )}
87− onClick={() => selectTurn(summary.turnId)}
88− title={summary.turnId}
89− >
90− <div className="text-[10px] font-medium">Turn {turnDiffSummaries.length - index}</div>
91− <div className="text-[10px] opacity-70">{formatTimestamp(summary.completedAt)}</div>
92− </button>
93− ))}
94− </div>
95− </div>
96−
97− <div className="min-h-0 flex flex-1">
98− <div className="w-[220px] shrink-0 border-r border-border px-2 py-2">
99− <p className="mb-1.5 px-1 text-[10px] uppercase tracking-[0.12em] text-muted-foreground/60">
100− Files
483+ <div ref={patchViewportRef} className="min-w-0 flex-1 overflow-auto px-3 py-2">
484+ {!canApplyStoredTarget && state.diffThreadId && (
485+ <p className="mb-2 text-[11px] text-muted-foreground/65">
486+ Showing diffs for the active thread.
101487 </p>
102− <div className="space-y-1 overflow-y-auto">
103− {selectedTurn?.files.map((file) => (
104− <button
105− key={`${selectedTurn.turnId}:${file.path}`}
106− type="button"
107− className={cn(
108− "flex w-full items-center justify-between gap-2 rounded-md border px-2 py-1.5 text-left font-mono text-[11px] transition-colors",
109− selectedFile?.path === file.path
110− ? "border-border bg-accent text-accent-foreground"
111− : "border-border/60 bg-background/60 text-muted-foreground/85 hover:border-border hover:text-foreground/90",
112− )}
113− onClick={() => selectFile(file.path)}
488+ )}
489+ {checkpointDiffError && !renderablePatch && (
490+ <p className="mb-2 text-[11px] text-red-500/80">{checkpointDiffError}</p>
491+ )}
492+ {!renderablePatch ? (
493+ <div className="flex h-full items-center justify-center text-xs text-muted-foreground/70">
494+ {isLoadingCheckpointDiff
495+ ? "Loading checkpoint diff..."
496+ : "No patch available for this selection."}
497+ </div>
498+ ) : renderablePatch.kind === "files" ? (
499+ <div className="space-y-3">
500+ {renderableFiles.map((fileDiff) => (
501+ <div
502+ key={fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`}
503+ data-diff-file-path={resolveFileDiffPath(fileDiff)}
504+ className="rounded-md"
114505 >
115− <span className="truncate">{file.path}</span>
116− {file.kind && (
117− <span className="rounded-sm border border-border/60 px-1 py-0.5 text-[9px] uppercase tracking-[0.08em]">
118− {file.kind}
119− </span>
120− )}
121− </button>
506+ <FileDiff
507+ fileDiff={fileDiff}
508+ options={{
509+ diffStyle: diffRenderMode === "split" ? "split" : "unified",
510+ lineDiffType: "none",
511+ themeType: resolvedTheme,
512+ }}
513+ />
514+ </div>
122515 ))}
123516 </div>
124− </div>
125−
126− <div className="min-w-0 flex-1 overflow-auto px-3 py-2">
127− {!canApplyStoredTarget && state.diffThreadId && (
128− <p className="mb-2 text-[11px] text-muted-foreground/65">
129− Showing diffs for the active thread.
130− </p>
131− )}
132− {!selectedPatch ? (
133− <div className="flex h-full items-center justify-center text-xs text-muted-foreground/70">
134− No patch available for this selection.
135− </div>
136− ) : (
137− <PatchDiff patch={selectedPatch} />
138− )}
139− </div>
517+ ) : (
518+ <div className="space-y-2">
519+ <p className="text-[11px] text-muted-foreground/75">{renderablePatch.reason}</p>
520+ <pre className="max-h-[72vh] overflow-auto rounded-md border border-border/70 bg-background/70 p-3 font-mono text-[11px] leading-relaxed text-muted-foreground/90">
521+ {renderablePatch.text}
522+ </pre>
523+ </div>
524+ )}
140525 </div>
141526 </>
142527 )}
apps/web/src/components/Sidebar.tsx+1−0
@@ -170,6 +170,7 @@ export default function Sidebar() { · 169 unmodified lines
170170 session: null,
171171 messages: [],
172172 events: [],
173+ turnDiffSummaries: [],
173174 error: null,
174175 createdAt: new Date().toISOString(),
175176 branch: options?.branch ?? null,
apps/web/src/hooks/useMediaQuery.ts+27−0
@@ -0,0 +1,27 @@
1+import { useEffect, useState } from "react";
2+
3+function getMediaQueryMatch(query: string): boolean {
4+ if (typeof window === "undefined") {
5+ return false;
6+ }
7+ return window.matchMedia(query).matches;
8+}
9+
10+export function useMediaQuery(query: string): boolean {
11+ const [matches, setMatches] = useState(() => getMediaQueryMatch(query));
12+
13+ useEffect(() => {
14+ const mediaQueryList = window.matchMedia(query);
15+ const handleChange = () => {
16+ setMatches(mediaQueryList.matches);
17+ };
18+
19+ setMatches(mediaQueryList.matches);
20+ mediaQueryList.addEventListener("change", handleChange);
21+ return () => {
22+ mediaQueryList.removeEventListener("change", handleChange);
23+ };
24+ }, [query]);
25+
26+ return matches;
27+}
apps/web/src/persistenceSchema.test.ts+98−0
@@ -263,6 +263,68 @@ describe("hydratePersistedState", () => { · 262 unmodified lines
263263 ]);
264264 expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`);
265265 });
266+
267+ it("hydrates persisted turn diff summaries", () => {
268+ const payload = JSON.stringify({
269+ version: 7,
270+ runtimeMode: "full-access",
271+ projects: [
272+ {
273+ id: "p-1",
274+ name: "Project",
275+ cwd: "/tmp/project",
276+ model: "gpt-5.3-codex",
277+ expanded: true,
278+ },
279+ ],
280+ threads: [
281+ {
282+ id: "t-1",
283+ codexThreadId: "thr_1",
284+ projectId: "p-1",
285+ title: "Thread",
286+ model: "gpt-5.3-codex",
287+ messages: [],
288+ createdAt: "2026-02-08T10:00:00.000Z",
289+ turnDiffSummaries: [
290+ {
291+ turnId: "turn-1",
292+ completedAt: "2026-02-08T10:05:00.000Z",
293+ checkpointTurnCount: 1,
294+ checkpointDiffLoaded: true,
295+ files: [
296+ {
297+ path: "src/app.ts",
298+ kind: "modified",
299+ additions: 3,
300+ deletions: 1,
301+ },
302+ ],
303+ },
304+ ],
305+ },
306+ ],
307+ activeThreadId: "t-1",
308+ });
309+
310+ const hydrated = hydratePersistedState(payload, false);
311+ expect(hydrated?.threads[0]?.turnDiffSummaries).toEqual([
312+ {
313+ turnId: "turn-1",
314+ completedAt: "2026-02-08T10:05:00.000Z",
315+ checkpointTurnCount: 1,
316+ checkpointDiffLoaded: true,
317+ files: [
318+ {
319+ path: "src/app.ts",
320+ kind: "modified",
321+ additions: 3,
322+ deletions: 1,
323+ },
324+ ],
325+ },
326+ ]);
327+ });
266328 });
267329
268330 describe("toPersistedState", () => {
@@ -309,6 +371,25 @@ describe("toPersistedState", () => { · 40 unmodified lines
309371 lastVisitedAt: "2026-02-08T10:02:00.000Z",
310372 branch: null,
311373 worktreePath: null,
374+ turnDiffSummaries: [
375+ {
376+ turnId: "turn-1",
377+ completedAt: "2026-02-08T10:05:00.000Z",
378+ status: "completed",
379+ checkpointTurnCount: 1,
380+ checkpointDiffLoaded: true,
381+ files: [
382+ {
383+ path: "src/app.ts",
384+ kind: "modified",
385+ additions: 3,
386+ deletions: 1,
387+ diff: "diff --git a/src/app.ts b/src/app.ts",
388+ },
389+ ],
390+ unifiedDiff: "diff --git a/src/app.ts b/src/app.ts",
391+ },
392+ ],
312393 };
313394
314395 const persisted = toPersistedState({
@@ -366,6 +447,23 @@ describe("toPersistedState", () => { · 51 unmodified lines
366447 lastVisitedAt: thread.lastVisitedAt,
367448 branch: null,
368449 worktreePath: null,
450+ turnDiffSummaries: [
451+ {
452+ turnId: "turn-1",
453+ completedAt: "2026-02-08T10:05:00.000Z",
454+ status: "completed",
455+ checkpointTurnCount: 1,
456+ checkpointDiffLoaded: true,
457+ files: [
458+ {
459+ path: "src/app.ts",
460+ kind: "modified",
461+ additions: 3,
462+ deletions: 1,
463+ },
464+ ],
465+ },
466+ ],
369467 });
370468 const persistedThread = persisted.threads[0];
371469 expect(persistedThread).toBeDefined();
apps/web/src/persistenceSchema.ts+50−0
@@ -47,6 +47,23 @@ const persistedTerminalGroupSchema = z.object({ · 46 unmodified lines
4747 terminalIds: z.array(z.string().trim().min(1)),
4848 });
4949
50+const persistedTurnDiffFileChangeSchema = z.object({
51+ path: z.string().min(1),
52+ kind: z.string().min(1).optional(),
53+ additions: z.number().int().min(0).optional(),
54+ deletions: z.number().int().min(0).optional(),
55+});
56+
57+const persistedTurnDiffSummarySchema = z.object({
58+ turnId: z.string().min(1),
59+ completedAt: z.string().min(1),
60+ status: z.string().min(1).optional(),
61+ files: z.array(persistedTurnDiffFileChangeSchema),
62+ assistantMessageId: z.string().min(1).optional(),
63+ checkpointTurnCount: z.number().int().min(0).optional(),
64+ checkpointDiffLoaded: z.boolean().optional(),
65+});
66+
5067 const persistedThreadSchema = z.object({
5168 id: z.string().min(1),
5269 codexThreadId: z.string().min(1).nullable().default(null),
@@ -67,6 +84,7 @@ const persistedThreadSchema = z.object({ · 14 unmodified lines
6784 lastVisitedAt: z.string().min(1).optional(),
6885 branch: z.string().min(1).nullable().optional(),
6986 worktreePath: z.string().min(1).nullable().optional(),
87+ turnDiffSummaries: z.array(persistedTurnDiffSummarySchema).default([]),
7088 });
7189
7290 const persistedStateBodySchema = z.object({
@@ -273,6 +291,22 @@ function hydrateThread( · 200 unmodified lines
273291 lastVisitedAt: thread.lastVisitedAt,
274292 branch: thread.branch ?? null,
275293 worktreePath: thread.worktreePath ?? null,
294+ turnDiffSummaries: thread.turnDiffSummaries.map((summary) => ({
295+ turnId: summary.turnId,
296+ completedAt: summary.completedAt,
297+ ...(summary.status ? { status: summary.status } : {}),
298+ files: summary.files.map((file) => ({
299+ path: file.path,
300+ ...(file.kind ? { kind: file.kind } : {}),
301+ ...(typeof file.additions === "number" ? { additions: file.additions } : {}),
302+ ...(typeof file.deletions === "number" ? { deletions: file.deletions } : {}),
303+ })),
304+ ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}),
305+ ...(typeof summary.checkpointTurnCount === "number"
306+ ? { checkpointTurnCount: summary.checkpointTurnCount }
307+ : {}),
308+ ...(summary.checkpointDiffLoaded ? { checkpointDiffLoaded: true } : {}),
309+ })),
276310 };
277311 }
278312
@@ -353,6 +387,22 @@ export function toPersistedState( · 74 unmodified lines
353387 ...(thread.lastVisitedAt ? { lastVisitedAt: thread.lastVisitedAt } : {}),
354388 branch: thread.branch,
355389 worktreePath: thread.worktreePath,
390+ turnDiffSummaries: thread.turnDiffSummaries.map((summary) => ({
391+ turnId: summary.turnId,
392+ completedAt: summary.completedAt,
393+ ...(summary.status ? { status: summary.status } : {}),
394+ files: summary.files.map((file) => ({
395+ path: file.path,
396+ ...(file.kind ? { kind: file.kind } : {}),
397+ ...(typeof file.additions === "number" ? { additions: file.additions } : {}),
398+ ...(typeof file.deletions === "number" ? { deletions: file.deletions } : {}),
399+ })),
400+ ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}),
401+ ...(typeof summary.checkpointTurnCount === "number"
402+ ? { checkpointTurnCount: summary.checkpointTurnCount }
403+ : {}),
404+ ...(summary.checkpointDiffLoaded ? { checkpointDiffLoaded: true } : {}),
405+ })),
356406 })),
357407 activeThreadId: state.activeThreadId,
358408 runtimeMode: state.runtimeMode,
apps/web/src/session-logic.test.ts+53−3
@@ -5,6 +5,7 @@ import { · 4 unmodified lines
55 type WorkLogEntry,
66 applyEventToMessages,
77 derivePendingApprovals,
8+ deriveTurnDiffFilesFromUnifiedDiff,
89 deriveTurnDiffSummaries,
910 deriveTimelineEntries,
1011 deriveWorkLogEntries,
@@ -395,7 +396,7 @@ describe("deriveWorkLogEntries", () => { · 384 unmodified lines
395396 });
396397
397398 describe("deriveTurnDiffSummaries", () => {
398− it("collects per-turn file changes and links them to assistant message completion", () => {
399+ it("tracks completed turns and links them to assistant message completion", () => {
399400 const summaries = deriveTurnDiffSummaries([
400401 makeEvent({
401402 id: "evt-turn-start",
@@ -492,8 +493,57 @@ describe("deriveTurnDiffSummaries", () => { · 90 unmodified lines
492493 expect(summaries).toHaveLength(1);
493494 expect(summaries[0]?.turnId).toBe("turn-1");
494495 expect(summaries[0]?.assistantMessageId).toBe("msg-1");
495− expect(summaries[0]?.files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]);
496− expect(summaries[0]?.files[0]?.kind).toBe("modified");
496+ expect(summaries[0]?.files).toEqual([]);
497+ });
498+
499+ it("includes completed turns even when no fileChange tool events were emitted", () => {
500+ const summaries = deriveTurnDiffSummaries([
501+ makeEvent({
502+ id: "evt-turn-start",
503+ method: "turn/started",
504+ turnId: "turn-1",
505+ createdAt: "2026-02-08T10:00:00.000Z",
506+ }),
507+ makeEvent({
508+ id: "evt-turn-completed",
509+ method: "turn/completed",
510+ turnId: "turn-1",
511+ createdAt: "2026-02-08T10:00:01.000Z",
512+ payload: {
513+ turn: {
514+ id: "turn-1",
515+ status: "completed",
516+ },
517+ },
518+ }),
519+ ]);
520+
521+ expect(summaries).toHaveLength(1);
522+ expect(summaries[0]?.turnId).toBe("turn-1");
523+ expect(summaries[0]?.files).toEqual([]);
524+ });
525+});
526+
527+describe("deriveTurnDiffFilesFromUnifiedDiff", () => {
528+ it("splits a multi-file patch into sorted per-file entries", () => {
529+ const files = deriveTurnDiffFilesFromUnifiedDiff(
530+ [
531+ "diff --git a/src/b.ts b/src/b.ts",
532+ "@@ -1 +1 @@",
533+ "-old-b",
534+ "+new-b",
535+ "diff --git a/src/a.ts b/src/a.ts",
536+ "@@ -1 +1 @@",
537+ "-old-a",
538+ "+new-a",
539+ ].join("\n"),
540+ );
541+
542+ expect(files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]);
543+ expect(files[0]?.diff).toContain("diff --git a/src/a.ts b/src/a.ts");
544+ expect(files[1]?.diff).toContain("diff --git a/src/b.ts b/src/b.ts");
545+ expect(files[0]?.additions).toBe(1);
546+ expect(files[0]?.deletions).toBe(1);
497547 });
498548 });
499549
apps/web/src/session-logic.ts+65−114
@@ -1,5 +1,5 @@
11 import type { NativeApi, ProviderEvent, ProviderKind, ProviderSession } from "@t3tools/contracts";
2−import type { ChatMessage, SessionPhase } from "./types";
2+import type { ChatMessage, SessionPhase, TurnDiffFileChange, TurnDiffSummary } from "./types";
33 import { createWsNativeApi } from "./wsNativeApi";
44
55 export const PROVIDER_OPTIONS: Array<{
@@ -99,20 +99,7 @@ export type TimelineEntry = · 93 unmodified lines
9999 entry: WorkLogEntry;
100100 };
101101
102−export interface TurnDiffFileChange {
103− path: string;
104− kind?: string;
105− diff?: string;
106−}
107−
108−export interface TurnDiffSummary {
109− turnId: string;
110− completedAt: string;
111− status?: string;
112− files: TurnDiffFileChange[];
113− unifiedDiff?: string;
114− assistantMessageId?: string;
115−}
102+export type { TurnDiffFileChange, TurnDiffSummary } from "./types";
116103
117104 function normalizeDetail(value: string | undefined): string | undefined {
118105 if (!value) return undefined;
@@ -120,10 +107,6 @@ function normalizeDetail(value: string | undefined): string | undefined { · 1 unmodified line
120107 return trimmed.length > 0 ? trimmed : undefined;
121108 }
122109
123−function asArray(value: unknown): unknown[] | undefined {
124− return Array.isArray(value) ? value : undefined;
125−}
126−
127110 function approvalDetail(event: ProviderEvent): string | undefined {
128111 const payload = asObject(event.payload);
129112 const command = asString(payload?.command);
@@ -457,19 +440,11 @@ export function deriveWorkLogEntries( · 327 unmodified lines
457440 return entries;
458441 }
459442
460−interface MutableTurnDiffFileChange {
461− path: string;
462− kind: string | undefined;
463− diff: string | undefined;
464−}
465−
466443 interface MutableTurnDiffSummary {
467444 turnId: string;
468445 completedAt: string | undefined;
469446 status: string | undefined;
470− unifiedDiff: string | undefined;
471447 assistantMessageId: string | undefined;
472− filesByPath: Map<string, MutableTurnDiffFileChange>;
473448 }
474449
475450 function parsePathFromDiff(diff: string): string | undefined {
@@ -485,7 +460,7 @@ function parsePathFromDiff(diff: string): string | undefined { · 9 unmodified lines
485460 return direct[1];
486461 }
487462
488−function splitUnifiedDiffByFile(diff: string): Map<string, string> {
463+export function splitUnifiedDiffByFile(diff: string): Map<string, string> {
489464 const normalized = diff.replace(/\r\n/g, "\n");
490465 const byPath = new Map<string, string>();
491466 const headerMatches = [...normalized.matchAll(/^diff --git a\/(.+) b\/(.+)$/gm)];
@@ -512,38 +487,71 @@ function splitUnifiedDiffByFile(diff: string): Map<string, string> { · 20 unmodified lines
512487 return byPath;
513488 }
514489
515−function parseFileChangeEntriesFromEvent(event: ProviderEvent): TurnDiffFileChange[] {
516− if (event.method !== "item/started" && event.method !== "item/completed") {
517− return [];
490+export function deriveTurnDiffFilesFromUnifiedDiff(diff: string): TurnDiffFileChange[] {
491+ const fileDiffsByPath = splitUnifiedDiffByFile(diff);
492+ return Array.from(fileDiffsByPath.entries())
493+ .map(([path, fileDiff]) => {
494+ const stat = countDiffStat(fileDiff);
495+ return {
496+ path,
497+ diff: fileDiff,
498+ additions: stat.additions,
499+ deletions: stat.deletions,
500+ };
501+ })
502+ .toSorted((a, b) => a.path.localeCompare(b.path));
503+}
504+
505+export function inferCheckpointTurnCountByTurnId(
506+ summaries: TurnDiffSummary[],
507+): Record<string, number> {
508+ if (summaries.length === 0) {
509+ return {};
518510 }
519511
520− const payload = asObject(event.payload);
521− const item = asObject(payload?.item);
522− if (asString(item?.type) !== "fileChange") {
523− return [];
524− }
525−
526− const changes = asArray(item?.changes);
527− if (!changes || changes.length === 0) {
528− return [];
529− }
530−
531− const files: TurnDiffFileChange[] = [];
532− for (const rawChange of changes) {
533− const change = asObject(rawChange);
534− if (!change) continue;
535− const diff = normalizeDetail(asString(change.diff));
536− const path = normalizeDetail(asString(change.path)) ?? (diff ? parsePathFromDiff(diff) : undefined);
537− if (!path) continue;
538− const kind = normalizeDetail(asString(change.kind));
539− files.push({
540− path,
541− ...(kind ? { kind } : {}),
542− ...(diff ? { diff } : {}),
543− });
512+ const sorted = [...summaries].toSorted((a, b) => {
513+ const aTime = Date.parse(a.completedAt);
514+ const bTime = Date.parse(b.completedAt);
515+ if (Number.isNaN(aTime) || Number.isNaN(bTime)) {
516+ return a.completedAt.localeCompare(b.completedAt);
517+ }
518+ return aTime - bTime;
519+ });
520+
521+ const next: Record<string, number> = {};
522+ let fallbackTurnCount = 1;
523+ for (const summary of sorted) {
524+ if (typeof summary.checkpointTurnCount === "number") {
525+ next[summary.turnId] = summary.checkpointTurnCount;
526+ fallbackTurnCount = Math.max(fallbackTurnCount, summary.checkpointTurnCount + 1);
527+ continue;
528+ }
529+ next[summary.turnId] = fallbackTurnCount;
530+ fallbackTurnCount += 1;
531+ }
532+
533+ return next;
534+}
535+
536+export function countDiffStat(patch: string | undefined): { additions: number; deletions: number } {
537+ if (!patch) {
538+ return { additions: 0, deletions: 0 };
539+ }
540+
541+ let additions = 0;
542+ let deletions = 0;
543+ for (const line of patch.replace(/\r\n/g, "\n").split("\n")) {
544+ if (line.startsWith("+++ ") || line.startsWith("--- ")) continue;
545+ if (line.startsWith("+")) {
546+ additions += 1;
547+ continue;
548+ }
549+ if (line.startsWith("-")) {
550+ deletions += 1;
551+ }
544552 }
545553
546− return files;
554+ return { additions, deletions };
547555 }
548556
549557 export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummary[] {
@@ -557,9 +565,7 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar · 7 unmodified lines
557565 turnId,
558566 completedAt: undefined,
559567 status: undefined,
560− unifiedDiff: undefined,
561568 assistantMessageId: undefined,
562− filesByPath: new Map(),
563569 };
564570 byTurnId.set(turnId, next);
565571 return next;
@@ -577,12 +583,6 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar · 11 unmodified lines
577583 continue;
578584 }
579585
580− if (event.method === "turn/diff/updated") {
581− const diff = normalizeDetail(asString(asObject(event.payload)?.diff));
582− if (diff) summary.unifiedDiff = diff;
583− continue;
584− }
585−
586586 if (event.method === "item/completed") {
587587 const item = asObject(asObject(event.payload)?.item);
588588 if (asString(item?.type) === "agentMessage") {
@@ -592,66 +592,17 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar · 3 unmodified lines
592592 }
593593 }
594594 }
595−
596− for (const change of parseFileChangeEntriesFromEvent(event)) {
597− const existing = summary.filesByPath.get(change.path);
598− if (!existing) {
599− summary.filesByPath.set(change.path, {
600− path: change.path,
601− kind: change.kind,
602− diff: change.diff,
603− });
604− continue;
605− }
606− summary.filesByPath.set(change.path, {
607− path: change.path,
608− kind: change.kind ?? existing.kind,
609− diff: change.diff ?? existing.diff,
610− });
611− }
612595 }
613596
614597 const summaries: TurnDiffSummary[] = [];
615598 for (const summary of byTurnId.values()) {
616599 if (!summary.completedAt) continue;
617600
618− const filesByPath = new Map(summary.filesByPath);
619− if (summary.unifiedDiff) {
620− const fileDiffs = splitUnifiedDiffByFile(summary.unifiedDiff);
621− for (const [path, diff] of fileDiffs) {
622− const existing = filesByPath.get(path);
623− if (!existing) {
624− filesByPath.set(path, { path, kind: undefined, diff });
625− continue;
626− }
627− if (!existing.diff) {
628− filesByPath.set(path, { ...existing, diff });
629− }
630− }
631− }
632−
633− const files: TurnDiffFileChange[] = [];
634− for (const file of Array.from(filesByPath.values()).toSorted((a, b) =>
635− a.path.localeCompare(b.path),
636− )) {
637− const nextFile: TurnDiffFileChange = { path: file.path };
638− if (file.kind) {
639− nextFile.kind = file.kind;
640− }
641− if (file.diff) {
642− nextFile.diff = file.diff;
643− }
644− files.push(nextFile);
645− }
646−
647− if (files.length === 0) continue;
648−
649601 summaries.push({
650602 turnId: summary.turnId,
651603 completedAt: summary.completedAt,
652604 ...(summary.status ? { status: summary.status } : {}),
653− files,
654− ...(summary.unifiedDiff ? { unifiedDiff: summary.unifiedDiff } : {}),
605+ files: [],
655606 ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}),
656607 });
657608 }
apps/web/src/store.test.ts+139−0
@@ -57,6 +57,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 56 unmodified lines
5757 session: makeSession(),
5858 messages: [],
5959 events: [],
60+ turnDiffSummaries: [],
6061 error: null,
6162 createdAt: "2026-02-09T00:00:00.000Z",
6263 branch: null,
@@ -517,6 +518,57 @@ describe("store reducer thread continuity", () => { · 454 unmodified lines
517518 expect(next.threads[0]?.session?.threadId).toBe("thr_new");
518519 });
519520
521+ it("preserves persisted turn diffs when events were reset and appends new completed turn diffs", () => {
522+ const state = makeState(
523+ makeThread({
524+ events: [],
525+ turnDiffSummaries: [
526+ {
527+ turnId: "turn-1",
528+ completedAt: "2026-02-09T00:00:01.000Z",
529+ files: [{ path: "src/existing.ts", kind: "modified" }],
530+ },
531+ ],
532+ }),
533+ );
534+
535+ const withFileChange = reducer(state, {
536+ type: "APPLY_EVENT",
537+ event: makeEvent({
538+ method: "item/completed",
539+ turnId: "turn-2",
540+ createdAt: "2026-02-09T00:00:02.000Z",
541+ payload: {
542+ item: {
543+ type: "fileChange",
544+ changes: [{ path: "src/new.ts", kind: "added" }],
545+ },
546+ },
547+ }),
548+ activeAssistantItemRef: { current: null },
549+ });
550+
551+ expect(withFileChange.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([
552+ "turn-1",
553+ ]);
554+
555+ const completed = reducer(withFileChange, {
556+ type: "APPLY_EVENT",
557+ event: makeEvent({
558+ method: "turn/completed",
559+ turnId: "turn-2",
560+ createdAt: "2026-02-09T00:00:03.000Z",
561+ payload: { turn: { id: "turn-2", status: "completed" } },
562+ }),
563+ activeAssistantItemRef: { current: null },
564+ });
565+
566+ expect(completed.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([
567+ "turn-2",
568+ "turn-1",
569+ ]);
570+ });
571+
520572 it("reconciles project ids by cwd when syncing backend projects", () => {
521573 const state: AppState = {
522574 projects: [
@@ -781,6 +833,20 @@ describe("store reducer thread continuity", () => { · 258 unmodified lines
781833 turnId: "turn-live",
782834 }),
783835 ],
836+ turnDiffSummaries: [
837+ {
838+ turnId: "turn_1",
839+ completedAt: "2026-02-08T10:00:01.000Z",
840+ files: [{ path: "src/first.ts", kind: "modified" }],
841+ checkpointTurnCount: 1,
842+ },
843+ {
844+ turnId: "turn_2",
845+ completedAt: "2026-02-08T10:00:03.000Z",
846+ files: [{ path: "src/second.ts", kind: "modified" }],
847+ checkpointTurnCount: 2,
848+ },
849+ ],
784850 error: "temporary failure",
785851 latestTurnId: "turn-live",
786852 latestTurnStartedAt: "2026-02-08T10:00:03.000Z",
@@ -792,6 +858,7 @@ describe("store reducer thread continuity", () => { · 5 unmodified lines
792858 threadId: "thread-local-1",
793859 sessionId: "sess-1",
794860 threadRuntimeId: "thr_after",
861+ turnCount: 1,
795862 messageCount: 2,
796863 });
797864
@@ -803,5 +870,77 @@ describe("store reducer thread continuity", () => { · 5 unmodified lines
803870 expect(next.threads[0]?.session?.activeTurnId).toBeUndefined();
804871 expect(next.threads[0]?.session?.threadId).toBe("thr_after");
805872 expect(next.threads[0]?.latestTurnId).toBeUndefined();
873+ expect(next.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn_1"]);
874+ });
875+
876+ it("keeps checkpoint-derived turn files when later events for the same turn arrive", () => {
877+ const state = makeState(
878+ makeThread({
879+ turnDiffSummaries: [
880+ {
881+ turnId: "turn-1",
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" }],
884+ unifiedDiff: "diff --git a/src/from-checkpoint.ts b/src/from-checkpoint.ts",
885+ checkpointDiffLoaded: true,
886+ },
887+ ],
888+ }),
889+ );
890+
891+ const next = reducer(state, {
892+ type: "APPLY_EVENT",
893+ event: makeEvent({
894+ method: "turn/completed",
895+ turnId: "turn-1",
896+ createdAt: "2026-02-09T00:00:04.000Z",
897+ payload: { turn: { id: "turn-1", status: "completed" } },
898+ }),
899+ activeAssistantItemRef: { current: null },
900+ });
901+
902+ expect(next.threads[0]?.turnDiffSummaries[0]?.files.map((file) => file.path)).toEqual([
903+ "src/from-checkpoint.ts",
904+ ]);
905+ expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointDiffLoaded).toBe(true);
906+ expect(next.threads[0]?.turnDiffSummaries[0]?.unifiedDiff).toContain("from-checkpoint.ts");
907+ });
908+
909+ it("updates turn summaries from checkpoint diffs and marks them as loaded", () => {
910+ const state = makeState(
911+ makeThread({
912+ turnDiffSummaries: [
913+ {
914+ turnId: "turn-1",
915+ completedAt: "2026-02-09T00:00:03.000Z",
916+ files: [],
917+ checkpointTurnCount: 1,
918+ },
919+ ],
920+ }),
921+ );
922+
923+ const next = reducer(state, {
924+ type: "SET_THREAD_TURN_CHECKPOINT_DIFFS",
925+ threadId: "thread-local-1",
926+ checkpointDiffByTurnId: {
927+ "turn-1": [
928+ "diff --git a/src/a.ts b/src/a.ts",
929+ "@@ -1 +1 @@",
930+ "-old-a",
931+ "+new-a",
932+ "diff --git a/src/b.ts b/src/b.ts",
933+ "@@ -1 +1 @@",
934+ "-old-b",
935+ "+new-b",
936+ ].join("\n"),
937+ },
938+ });
939+
940+ expect(next.threads[0]?.turnDiffSummaries[0]?.files.map((file) => file.path)).toEqual([
941+ "src/a.ts",
942+ "src/b.ts",
943+ ]);
944+ expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointDiffLoaded).toBe(true);
806945 });
807946 });
apps/web/src/store.ts+201−22
@@ -11,7 +11,15 @@ import { · 10 unmodified lines
1111 import { type ProviderEvent, type ProviderSession, type TerminalEvent, normalizeProjectScripts } from "@t3tools/contracts";
1212 import { resolveModelSlug } from "./model-logic";
1313 import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
14−import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic";
14+import {
15+ applyEventToMessages,
16+ asObject,
17+ asString,
18+ deriveTurnDiffFilesFromUnifiedDiff,
19+ deriveTurnDiffSummaries,
20+ inferCheckpointTurnCountByTurnId,
21+ evolveSession,
22+} from "./session-logic";
1523 import {
1624 type ChatAttachment,
1725 DEFAULT_THREAD_TERMINAL_ID,
@@ -77,8 +85,19 @@ type Action = · 59 unmodified lines
7785 threadId: string;
7886 sessionId: string;
7987 threadRuntimeId: string;
88+ turnCount: number;
8089 messageCount: number;
8190 }
91+ | {
92+ type: "SET_THREAD_TURN_CHECKPOINT_COUNTS";
93+ threadId: string;
94+ checkpointTurnCountByTurnId: Record<string, number>;
95+ }
96+ | {
97+ type: "SET_THREAD_TURN_CHECKPOINT_DIFFS";
98+ threadId: string;
99+ checkpointDiffByTurnId: Record<string, string>;
100+ }
82101 | {
83102 type: "SET_THREAD_BRANCH";
84103 threadId: string;
@@ -179,6 +198,77 @@ function updateThread( · 94 unmodified lines
179198 return threads.map((t) => (t.id === threadId ? updater(t) : t));
180199 }
181200
201+function mergeTurnDiffSummaries(
202+ existing: Thread["turnDiffSummaries"],
203+ next: Thread["turnDiffSummaries"],
204+): Thread["turnDiffSummaries"] {
205+ if (existing.length === 0) return next;
206+ if (next.length === 0) return existing;
207+
208+ const existingByTurnId = new Map(existing.map((summary) => [summary.turnId, summary] as const));
209+ const merged = next.map((summary) => {
210+ const previous = existingByTurnId.get(summary.turnId);
211+ if (!previous) {
212+ return summary;
213+ }
214+
215+ const prefersCheckpointDiff = previous.checkpointDiffLoaded || summary.checkpointDiffLoaded;
216+ const files =
217+ prefersCheckpointDiff ||
218+ (summary.files.length === 0 && previous.files.length > 0 && summary.unifiedDiff === undefined)
219+ ? previous.files
220+ : summary.files;
221+ const unifiedDiff =
222+ prefersCheckpointDiff || summary.unifiedDiff === undefined
223+ ? (previous.unifiedDiff ?? summary.unifiedDiff)
224+ : summary.unifiedDiff;
225+
226+ return {
227+ ...summary,
228+ files,
229+ ...(unifiedDiff !== undefined ? { unifiedDiff } : {}),
230+ ...(summary.assistantMessageId
231+ ? {}
232+ : previous.assistantMessageId
233+ ? { assistantMessageId: previous.assistantMessageId }
234+ : {}),
235+ ...(typeof summary.checkpointTurnCount === "number"
236+ ? {}
237+ : typeof previous.checkpointTurnCount === "number"
238+ ? { checkpointTurnCount: previous.checkpointTurnCount }
239+ : {}),
240+ ...(summary.checkpointDiffLoaded || previous.checkpointDiffLoaded
241+ ? { checkpointDiffLoaded: true }
242+ : {}),
243+ };
244+ });
245+
246+ const mergedTurnIds = new Set(merged.map((summary) => summary.turnId));
247+ for (const summary of existing) {
248+ if (!mergedTurnIds.has(summary.turnId)) {
249+ merged.push(summary);
250+ }
251+ }
252+
253+ const sorted = merged.toSorted((a, b) => {
254+ const aTime = Date.parse(a.completedAt);
255+ const bTime = Date.parse(b.completedAt);
256+ if (Number.isNaN(aTime) || Number.isNaN(bTime)) {
257+ return b.completedAt.localeCompare(a.completedAt);
258+ }
259+ return bTime - aTime;
260+ });
261+
262+ const inferredTurnCountByTurnId = inferCheckpointTurnCountByTurnId(sorted);
263+ return sorted.map((summary) =>
264+ typeof summary.checkpointTurnCount === "number"
265+ ? summary
266+ : Object.assign({}, summary, {
267+ checkpointTurnCount: inferredTurnCountByTurnId[summary.turnId],
268+ }),
269+ );
270+}
271+
182272 function normalizeTerminalIds(terminalIds: string[]): string[] {
183273 const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0);
184274 const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT);
@@ -554,6 +644,7 @@ export function reducer(state: AppState, action: Action): AppState { · 369 unmodified lines
554644 ...action.thread,
555645 model: resolveModelSlug(action.thread.model),
556646 lastVisitedAt: action.thread.lastVisitedAt ?? action.thread.createdAt,
647+ turnDiffSummaries: action.thread.turnDiffSummaries ?? [],
557648 });
558649 return {
559650 ...state,
@@ -800,27 +891,32 @@ export function reducer(state: AppState, action: Action): AppState { · 240 unmodified lines
800891
801892 return {
802893 ...state,
803− threads: updateThread(state.threads, target.id, (t) => ({
804− ...t,
805− ...(() => {
806− const eventThreadId = getEventThreadId(event);
807− const shouldRebindIdentity =
808− event.method === "thread/started" && t.session?.status === "connecting";
809− return {
810− codexThreadId: shouldRebindIdentity
811− ? (eventThreadId ?? t.codexThreadId)
812− : (t.codexThreadId ?? eventThreadId ?? null),
813− error: event.kind === "error" && event.message ? event.message : t.error,
814− };
815− })(),
816− session: t.session ? evolveSession(t.session, event) : t.session,
817− messages: applyEventToMessages(t.messages, event, activeAssistantItemRef),
818− events: [event, ...t.events],
819− ...updateTurnFields(t, event),
820− ...(event.method === "turn/completed" && t.id === state.activeThreadId
821− ? { lastVisitedAt: event.createdAt }
822− : {}),
823− })),
894+ threads: updateThread(state.threads, target.id, (t) => {
895+ const nextEvents = [event, ...t.events];
896+ const derivedTurnDiffSummaries = deriveTurnDiffSummaries(nextEvents);
897+ const turnDiffSummaries = mergeTurnDiffSummaries(
898+ t.turnDiffSummaries,
899+ derivedTurnDiffSummaries,
900+ );
901+ const eventThreadId = getEventThreadId(event);
902+ const shouldRebindIdentity =
903+ event.method === "thread/started" && t.session?.status === "connecting";
904+ return {
905+ ...t,
906+ codexThreadId: shouldRebindIdentity
907+ ? (eventThreadId ?? t.codexThreadId)
908+ : (t.codexThreadId ?? eventThreadId ?? null),
909+ error: event.kind === "error" && event.message ? event.message : t.error,
910+ session: t.session ? evolveSession(t.session, event) : t.session,
911+ messages: applyEventToMessages(t.messages, event, activeAssistantItemRef),
912+ events: nextEvents,
913+ turnDiffSummaries,
914+ ...updateTurnFields(t, event),
915+ ...(event.method === "turn/completed" && t.id === state.activeThreadId
916+ ? { lastVisitedAt: event.createdAt }
917+ : {}),
918+ };
919+ }),
824920 };
825921 }
826922
@@ -893,6 +989,7 @@ export function reducer(state: AppState, action: Action): AppState { · 66 unmodified lines
893989 ...state,
894990 threads: updateThread(state.threads, action.threadId, (t) => {
895991 const nextMessageCount = Math.max(0, Math.floor(action.messageCount));
992+ const nextTurnCount = Math.max(0, Math.floor(action.turnCount));
896993 const now = new Date().toISOString();
897994 return {
898995 ...t,
@@ -910,6 +1007,11 @@ export function reducer(state: AppState, action: Action): AppState { · 11 unmodified lines
9101007 : t.session,
9111008 messages: t.messages.slice(0, nextMessageCount),
9121009 events: [],
1010+ turnDiffSummaries: t.turnDiffSummaries.filter(
1011+ (summary) =>
1012+ typeof summary.checkpointTurnCount === "number" &&
1013+ summary.checkpointTurnCount <= nextTurnCount,
1014+ ),
9131015 error: null,
9141016 latestTurnId: undefined,
9151017 latestTurnStartedAt: undefined,
@@ -920,6 +1022,83 @@ export function reducer(state: AppState, action: Action): AppState { · 4 unmodified lines
9201022 }),
9211023 };
9221024
1025+ case "SET_THREAD_TURN_CHECKPOINT_COUNTS":
1026+ return {
1027+ ...state,
1028+ threads: updateThread(state.threads, action.threadId, (t) => {
1029+ const hasUpdates = t.turnDiffSummaries.some(
1030+ (summary) =>
1031+ action.checkpointTurnCountByTurnId[summary.turnId] !== undefined &&
1032+ action.checkpointTurnCountByTurnId[summary.turnId] !== summary.checkpointTurnCount,
1033+ );
1034+ if (!hasUpdates) {
1035+ return t;
1036+ }
1037+ return {
1038+ ...t,
1039+ turnDiffSummaries: t.turnDiffSummaries.map((summary) => {
1040+ const turnCount = action.checkpointTurnCountByTurnId[summary.turnId];
1041+ if (turnCount === undefined) {
1042+ return summary;
1043+ }
1044+ return {
1045+ ...summary,
1046+ checkpointTurnCount: turnCount,
1047+ };
1048+ }),
1049+ };
1050+ }),
1051+ };
1052+
1053+ case "SET_THREAD_TURN_CHECKPOINT_DIFFS":
1054+ return {
1055+ ...state,
1056+ threads: updateThread(state.threads, action.threadId, (t) => {
1057+ let hasUpdates = false;
1058+ const updatedSummaries = t.turnDiffSummaries.map((summary) => {
1059+ const checkpointDiff = action.checkpointDiffByTurnId[summary.turnId];
1060+ if (checkpointDiff === undefined) {
1061+ return summary;
1062+ }
1063+
1064+ const files = deriveTurnDiffFilesFromUnifiedDiff(checkpointDiff);
1065+ const filesUnchanged =
1066+ files.length === summary.files.length &&
1067+ files.every((file, index) => {
1068+ const existing = summary.files[index];
1069+ return (
1070+ existing !== undefined &&
1071+ existing.path === file.path &&
1072+ existing.diff === file.diff &&
1073+ existing.kind === file.kind &&
1074+ existing.additions === file.additions &&
1075+ existing.deletions === file.deletions
1076+ );
1077+ });
1078+ if (summary.unifiedDiff === checkpointDiff && filesUnchanged && summary.checkpointDiffLoaded) {
1079+ return summary;
1080+ }
1081+
1082+ hasUpdates = true;
1083+ return {
1084+ ...summary,
1085+ unifiedDiff: checkpointDiff,
1086+ files,
1087+ checkpointDiffLoaded: true,
1088+ };
1089+ });
1090+
1091+ if (!hasUpdates) {
1092+ return t;
1093+ }
1094+
1095+ return {
1096+ ...t,
1097+ turnDiffSummaries: updatedSummaries,
1098+ };
1099+ }),
1100+ };
1101+
9231102 case "SET_THREAD_BRANCH": {
9241103 return {
9251104 ...state,
apps/web/src/types.ts+20−0
@@ -37,6 +37,25 @@ export interface ChatMessage { · 36 unmodified lines
3737 streaming: boolean;
3838 }
3939
40+export interface TurnDiffFileChange {
41+ path: string;
42+ kind?: string | undefined;
43+ diff?: string | undefined;
44+ additions?: number | undefined;
45+ deletions?: number | undefined;
46+}
47+
48+export interface TurnDiffSummary {
49+ turnId: string;
50+ completedAt: string;
51+ status?: string | undefined;
52+ files: TurnDiffFileChange[];
53+ unifiedDiff?: string | undefined;
54+ assistantMessageId?: string | undefined;
55+ checkpointTurnCount?: number | undefined;
56+ checkpointDiffLoaded?: boolean | undefined;
57+}
58+
4059 export interface Project {
4160 id: string;
4261 name: string;
@@ -71,4 +90,5 @@ export interface Thread { · 28 unmodified lines
7190 lastVisitedAt?: string | undefined;
7291 branch: string | null;
7392 worktreePath: string | null;
93+ turnDiffSummaries: TurnDiffSummary[];
7494 }
apps/web/src/worktreeCleanup.test.ts+1−0
@@ -25,6 +25,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 24 unmodified lines
2525 session: null,
2626 messages: [],
2727 events: [],
28+ turnDiffSummaries: [],
2829 error: null,
2930 createdAt: "2026-02-13T00:00:00.000Z",
3031 branch: null,
apps/web/src/wsNativeApi.ts+1−0
@@ -92,6 +92,7 @@ export function createWsNativeApi(): NativeApi { · 91 unmodified lines
9292 stopSession: (input) => transport.request(WS_METHODS.providersStopSession, input),
9393 listSessions: () => transport.request(WS_METHODS.providersListSessions),
9494 listCheckpoints: (input) => transport.request(WS_METHODS.providersListCheckpoints, input),
95+ getCheckpointDiff: (input) => transport.request(WS_METHODS.providersGetCheckpointDiff, input),
9596 revertToCheckpoint: (input) =>
9697 transport.request(WS_METHODS.providersRevertToCheckpoint, input),
9798 onEvent: (callback) =>
apps/web/src/wsTransport.ts+3−0
@@ -37,6 +37,9 @@ export class WsTransport { · 36 unmodified lines
3737 }
3838
3939 async request<T = unknown>(method: string, params?: unknown): Promise<T> {
40+ if (typeof method !== "string" || method.length === 0) {
41+ throw new Error("Request method is required");
42+ }
4043 const id = String(this.nextId++);
4144 const message: WsRequest = { id, method, ...(params !== undefined ? { params } : {}) };
4245
packages/contracts/package.json+1−1
@@ -12,7 +12,7 @@ · 11 unmodified lines
1212 "exports": {
1313 ".": {
1414 "types": "./src/index.ts",
15− "import": "./dist/index.mjs",
15+ "import": "./src/index.ts",
1616 "require": "./dist/index.cjs"
1717 }
1818 },
packages/contracts/src/ipc.ts+5−0
@@ -17,6 +17,8 @@ import type { · 16 unmodified lines
1717 } from "./git";
1818 import type {
1919 ProviderEvent,
20+ ProviderGetCheckpointDiffInput,
21+ ProviderGetCheckpointDiffResult,
2022 ProviderInterruptTurnInput,
2123 ProviderListCheckpointsInput,
2224 ProviderListCheckpointsResult,
@@ -94,6 +96,9 @@ export interface NativeApi { · 71 unmodified lines
9496 stopSession: (input: ProviderStopSessionInput) => Promise<void>;
9597 listSessions: () => Promise<ProviderSession[]>;
9698 listCheckpoints: (input: ProviderListCheckpointsInput) => Promise<ProviderListCheckpointsResult>;
99+ getCheckpointDiff: (
100+ input: ProviderGetCheckpointDiffInput,
101+ ) => Promise<ProviderGetCheckpointDiffResult>;
97102 revertToCheckpoint: (
98103 input: ProviderRevertToCheckpointInput,
99104 ) => Promise<ProviderRevertToCheckpointResult>;
packages/contracts/src/provider.test.ts+29−0
@@ -4,6 +4,8 @@ import { · 3 unmodified lines
44 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
55 providerCheckpointSchema,
66 providerEventSchema,
7+ providerGetCheckpointDiffInputSchema,
8+ providerGetCheckpointDiffResultSchema,
79 providerListCheckpointsInputSchema,
810 providerListCheckpointsResultSchema,
911 providerRevertToCheckpointInputSchema,
@@ -229,4 +231,31 @@ describe("provider checkpoint schemas", () => { · 219 unmodified lines
229231 }),
230232 ).toThrow();
231233 });
234+
235+ it("accepts checkpoint diff input/result", () => {
236+ const input = providerGetCheckpointDiffInputSchema.parse({
237+ sessionId: "sess_1",
238+ fromTurnCount: 1,
239+ toTurnCount: 2,
240+ });
241+ expect(input.fromTurnCount).toBe(1);
242+
243+ const result = providerGetCheckpointDiffResultSchema.parse({
244+ threadId: "thr_1",
245+ fromTurnCount: 1,
246+ toTurnCount: 2,
247+ diff: "diff --git a/src/app.ts b/src/app.ts",
248+ });
249+ expect(result.toTurnCount).toBe(2);
250+ });
251+
252+ it("rejects checkpoint diff ranges where start is after end", () => {
253+ expect(() =>
254+ providerGetCheckpointDiffInputSchema.parse({
255+ sessionId: "sess_1",
256+ fromTurnCount: 3,
257+ toTurnCount: 2,
258+ }),
259+ ).toThrow();
260+ });
232261 });
packages/contracts/src/provider.ts+25−0
@@ -142,6 +142,29 @@ export const providerRevertToCheckpointResultSchema = z.object({ · 141 unmodified lines
142142 checkpoints: z.array(providerCheckpointSchema),
143143 });
144144
145+export const providerGetCheckpointDiffInputSchema = z
146+ .object({
147+ sessionId: z.string().min(1),
148+ fromTurnCount: z.number().int().min(0),
149+ toTurnCount: z.number().int().min(0),
150+ })
151+ .superRefine((value, ctx) => {
152+ if (value.fromTurnCount > value.toTurnCount) {
153+ ctx.addIssue({
154+ code: z.ZodIssueCode.custom,
155+ message: "fromTurnCount must be less than or equal to toTurnCount",
156+ path: ["fromTurnCount"],
157+ });
158+ }
159+ });
160+
161+export const providerGetCheckpointDiffResultSchema = z.object({
162+ threadId: z.string().min(1),
163+ fromTurnCount: z.number().int().min(0),
164+ toTurnCount: z.number().int().min(0),
165+ diff: z.string(),
166+});
167+
145168 export const providerRespondToRequestInputSchema = z.object({
146169 sessionId: z.string().min(1),
147170 requestId: z.string().min(1),
@@ -189,6 +212,8 @@ export type ProviderRevertToCheckpointInput = z.input<typeof providerRevertToChe · 41 unmodified lines
189212 export type ProviderRevertToCheckpointResult = z.infer<
190213 typeof providerRevertToCheckpointResultSchema
191214 >;
215+export type ProviderGetCheckpointDiffInput = z.input<typeof providerGetCheckpointDiffInputSchema>;
216+export type ProviderGetCheckpointDiffResult = z.infer<typeof providerGetCheckpointDiffResultSchema>;
192217 export type ProviderRespondToRequestInput = z.input<typeof providerRespondToRequestInputSchema>;
193218 export type ProviderEventKind = z.infer<typeof providerEventKindSchema>;
194219 export type ProviderEvent = z.infer<typeof providerEventSchema>;
packages/contracts/src/ws.ts+1−0
@@ -11,6 +11,7 @@ export const WS_METHODS = { · 10 unmodified lines
1111 providersStopSession: "providers.stopSession",
1212 providersListSessions: "providers.listSessions",
1313 providersListCheckpoints: "providers.listCheckpoints",
14+ providersGetCheckpointDiff: "providers.getCheckpointDiff",
1415 providersRevertToCheckpoint: "providers.revertToCheckpoint",
1516
1617 // Project registry methods