authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-17 23:54:49-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-18 21:59:17-08:00
logcf8a2f03fc1c46b70faa565b2e00625cb8b58ebd
treeef6db473f17d622037f1779c38b631e5d7d19edd
parent582615971ef76e55f994e2fc0ccd9171277551c2

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

27 files changed, 2267 insertions(+), 480 deletions(-)

apps/server/src/codexAppServerManager.ts+4-3
...@@ -148,19 +148,20 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -148,19 +148,20 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
148 async startSession(input: ProviderSessionStartInput): Promise<ProviderSession> {148 async startSession(input: ProviderSessionStartInput): Promise<ProviderSession> {
149 const sessionId = randomUUID();149 const sessionId = randomUUID();
150 const now = new Date().toISOString();150 const now = new Date().toISOString();
151 const resolvedCwd = input.cwd ?? process.cwd();
151152
152 const session: ProviderSession = {153 const session: ProviderSession = {
153 sessionId,154 sessionId,
154 provider: "codex",155 provider: "codex",
155 status: "connecting",156 status: "connecting",
156 model: normalizeCodexModelSlug(input.model),157 model: normalizeCodexModelSlug(input.model),
157 cwd: input.cwd,158 cwd: resolvedCwd,
158 createdAt: now,159 createdAt: now,
159 updatedAt: now,160 updatedAt: now,
160 };161 };
161162
162 const child = spawn("codex", ["app-server"], {163 const child = spawn("codex", ["app-server"], {
163 cwd: input.cwd,164 cwd: resolvedCwd,
164 env: process.env,165 env: process.env,
165 stdio: ["pipe", "pipe", "pipe"],166 stdio: ["pipe", "pipe", "pipe"],
166 });167 });
...@@ -198,7 +199,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -198,7 +199,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
198 const normalizedModel = normalizeCodexModelSlug(input.model);199 const normalizedModel = normalizeCodexModelSlug(input.model);
199 const sessionOverrides = {200 const sessionOverrides = {
200 model: normalizedModel ?? null,201 model: normalizedModel ?? null,
201 cwd: input.cwd ?? null,202 cwd: resolvedCwd,
202 approvalPolicy: input.approvalPolicy,203 approvalPolicy: input.approvalPolicy,
203 sandbox: input.sandboxMode,204 sandbox: input.sandboxMode,
204 };205 };
apps/server/src/filesystemCheckpointStore.test.ts+75
...@@ -93,6 +93,32 @@ describe("FilesystemCheckpointStore", () => {...@@ -93,6 +93,32 @@ describe("FilesystemCheckpointStore", () => {
93 }93 }
94 });94 });
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
96 it("prunes refs newer than the retained turn", async () => {122 it("prunes refs newer than the retained turn", async () => {
97 const cwd = await createRepo();123 const cwd = await createRepo();
98 const store = new FilesystemCheckpointStore();124 const store = new FilesystemCheckpointStore();
...@@ -119,6 +145,55 @@ describe("FilesystemCheckpointStore", () => {...@@ -119,6 +145,55 @@ describe("FilesystemCheckpointStore", () => {
119 }145 }
120 });146 });
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
122 it("detects non-repo folders", async () => {197 it("detects non-repo folders", async () => {
123 const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoints-nonrepo-"));198 const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoints-nonrepo-"));
124 const store = new FilesystemCheckpointStore();199 const store = new FilesystemCheckpointStore();
apps/server/src/filesystemCheckpointStore.ts+79-1
...@@ -91,10 +91,30 @@ export class FilesystemCheckpointStore {...@@ -91,10 +91,30 @@ export class FilesystemCheckpointStore {
91 return commit !== null;91 return commit !== null;
92 }92 }
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
94 async restoreCheckpoint(input: { cwd: string; threadId: string; turnCount: number }): Promise<boolean> {111 async restoreCheckpoint(input: { cwd: string; threadId: string; turnCount: number }): Promise<boolean> {
95 const { cwd, threadId, turnCount } = input;112 const { cwd, threadId, turnCount } = input;
96 const ref = checkpointRefForThreadTurn(threadId, turnCount);113 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 }
98 if (!commitOid) {118 if (!commitOid) {
99 return false;119 return false;
100 }120 }
...@@ -105,6 +125,64 @@ export class FilesystemCheckpointStore {...@@ -105,6 +125,64 @@ export class FilesystemCheckpointStore {
105 return true;125 return true;
106 }126 }
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
108 async pruneAfterTurn(input: { cwd: string; threadId: string; maxTurnCount: number }): Promise<void> {186 async pruneAfterTurn(input: { cwd: string; threadId: string; maxTurnCount: number }): Promise<void> {
109 const { cwd, threadId, maxTurnCount } = input;187 const { cwd, threadId, maxTurnCount } = input;
110 if (!Number.isInteger(maxTurnCount) || maxTurnCount < 0) {188 if (!Number.isInteger(maxTurnCount) || maxTurnCount < 0) {
apps/server/src/providerManager.test.ts+337
...@@ -76,6 +76,14 @@ describe("ProviderManager", () => {...@@ -76,6 +76,14 @@ describe("ProviderManager", () => {
76 }),76 }),
77 ).rejects.toThrow("Unknown provider session: missing-session");77 ).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
79 manager.dispose();87 manager.dispose();
80 });88 });
8189
...@@ -155,12 +163,39 @@ describe("ProviderManager", () => {...@@ -155,12 +163,39 @@ describe("ProviderManager", () => {
155 threadId: string;163 threadId: string;
156 turns: Array<{ id: string; items: unknown[] }>;164 turns: Array<{ id: string; items: unknown[] }>;
157 }>;165 }>;
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>;
158 };178 };
159 }179 }
160 ).codex;180 ).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
162 const rollbackCalls: Array<{ sessionId: string; numTurns: number }> = [];188 const rollbackCalls: Array<{ sessionId: string; numTurns: number }> = [];
163 codex.hasSession = () => true;189 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 ];
164 codex.readThread = async () => ({199 codex.readThread = async () => ({
165 threadId: "thr_1",200 threadId: "thr_1",
166 turns: [201 turns: [
...@@ -176,6 +211,8 @@ describe("ProviderManager", () => {...@@ -176,6 +211,8 @@ describe("ProviderManager", () => {
176 turns: [{ id: "turn_1", items: [] }],211 turns: [{ id: "turn_1", items: [] }],
177 };212 };
178 };213 };
214 internals.filesystemCheckpointStore.isGitRepository = async () => false;
215 internals.filesystemCheckpointStore.ensureRootCheckpoint = async () => true;
179216
180 const result = await manager.revertToCheckpoint({217 const result = await manager.revertToCheckpoint({
181 sessionId: "sess_1",218 sessionId: "sess_1",
...@@ -192,6 +229,239 @@ describe("ProviderManager", () => {...@@ -192,6 +229,239 @@ describe("ProviderManager", () => {
192 manager.dispose();229 manager.dispose();
193 });230 });
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
195 it("restores filesystem checkpoint when reverting and checkpointing is enabled", async () => {465 it("restores filesystem checkpoint when reverting and checkpointing is enabled", async () => {
196 const manager = new ProviderManager();466 const manager = new ProviderManager();
197 const internals = manager as unknown as {467 const internals = manager as unknown as {
...@@ -263,6 +533,73 @@ describe("ProviderManager", () => {...@@ -263,6 +533,73 @@ describe("ProviderManager", () => {
263 manager.dispose();533 manager.dispose();
264 });534 });
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
266 it("fails before rollback when filesystem checkpoint is missing", async () => {603 it("fails before rollback when filesystem checkpoint is missing", async () => {
267 const manager = new ProviderManager();604 const manager = new ProviderManager();
268 const internals = manager as unknown as {605 const internals = manager as unknown as {
apps/server/src/providerManager.ts+105-5
...@@ -6,6 +6,8 @@ import { randomUUID } from "node:crypto";...@@ -6,6 +6,8 @@ import { randomUUID } from "node:crypto";
6import {6import {
7 type ProviderCheckpoint,7 type ProviderCheckpoint,
8 type ProviderEvent,8 type ProviderEvent,
9 type ProviderGetCheckpointDiffInput,
10 type ProviderGetCheckpointDiffResult,
9 type ProviderInterruptTurnInput,11 type ProviderInterruptTurnInput,
10 type ProviderListCheckpointsInput,12 type ProviderListCheckpointsInput,
11 type ProviderListCheckpointsResult,13 type ProviderListCheckpointsResult,
...@@ -18,6 +20,7 @@ import {...@@ -18,6 +20,7 @@ import {
18 type ProviderStopSessionInput,20 type ProviderStopSessionInput,
19 type ProviderTurnStartResult,21 type ProviderTurnStartResult,
20 providerInterruptTurnInputSchema,22 providerInterruptTurnInputSchema,
23 providerGetCheckpointDiffInputSchema,
21 providerListCheckpointsInputSchema,24 providerListCheckpointsInputSchema,
22 providerRevertToCheckpointInputSchema,25 providerRevertToCheckpointInputSchema,
23 providerRespondToRequestInputSchema,26 providerRespondToRequestInputSchema,
...@@ -198,7 +201,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {...@@ -198,7 +201,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
198 this.sessionThreadIds.set(session.sessionId, session.threadId);201 this.sessionThreadIds.set(session.sessionId, session.threadId);
199 this.flushPendingSessionEvents(session.sessionId, session.threadId);202 this.flushPendingSessionEvents(session.sessionId, session.threadId);
200 }203 }
201 await this.initializeFilesystemCheckpointing(session).catch((error) => {204 await this.initializeFilesystemCheckpointing(session, input.cwd).catch((error) => {
202 const message =205 const message =
203 error instanceof Error ? error.message : "Failed to initialize filesystem checkpoints.";206 error instanceof Error ? error.message : "Failed to initialize filesystem checkpoints.";
204 this.emitFilesystemCheckpointError(session.sessionId, message, session.threadId);207 this.emitFilesystemCheckpointError(session.sessionId, message, session.threadId);
...@@ -259,6 +262,43 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {...@@ -259,6 +262,43 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
259 };262 };
260 }263 }
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
262 async revertToCheckpoint(302 async revertToCheckpoint(
263 raw: ProviderRevertToCheckpointInput,303 raw: ProviderRevertToCheckpointInput,
264 ): Promise<ProviderRevertToCheckpointResult> {304 ): Promise<ProviderRevertToCheckpointResult> {
...@@ -267,6 +307,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {...@@ -267,6 +307,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
267 throw new Error(`Unknown provider session: ${input.sessionId}`);307 throw new Error(`Unknown provider session: ${input.sessionId}`);
268 }308 }
269309
310 const checkpointCwd = await this.getOrInitializeFilesystemCheckpointCwd(input.sessionId);
270 return this.withFilesystemLock(input.sessionId, async () => {311 return this.withFilesystemLock(input.sessionId, async () => {
271 const beforeSnapshot = await this.codex.readThread(input.sessionId);312 const beforeSnapshot = await this.codex.readThread(input.sessionId);
272 const currentTurnCount = beforeSnapshot.turns.length;313 const currentTurnCount = beforeSnapshot.turns.length;
...@@ -276,8 +317,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {...@@ -276,8 +317,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
276 );317 );
277 }318 }
278319
279 const checkpointCwd = this.sessionCheckpointCwds.get(input.sessionId);320 if (checkpointCwd && input.turnCount > 0) {
280 if (checkpointCwd) {
281 const hasCheckpoint = await this.filesystemCheckpointStore.hasCheckpoint({321 const hasCheckpoint = await this.filesystemCheckpointStore.hasCheckpoint({
282 cwd: checkpointCwd,322 cwd: checkpointCwd,
283 threadId: beforeSnapshot.threadId,323 threadId: beforeSnapshot.threadId,
...@@ -352,8 +392,11 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {...@@ -352,8 +392,11 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
352 this.filesystemLocks.clear();392 this.filesystemLocks.clear();
353 }393 }
354394
355 private async initializeFilesystemCheckpointing(session: ProviderSession): Promise<void> {395 private async initializeFilesystemCheckpointing(
356 const cwd = session.cwd ?? process.cwd();396 session: ProviderSession,
397 preferredCwd?: string,
398 ): Promise<void> {
399 const cwd = preferredCwd ?? session.cwd ?? process.cwd();
357400
358 await this.withFilesystemLock(session.sessionId, async () => {401 await this.withFilesystemLock(session.sessionId, async () => {
359 const supportsGit = await this.filesystemCheckpointStore.isGitRepository(cwd);402 const supportsGit = await this.filesystemCheckpointStore.isGitRepository(cwd);
...@@ -363,6 +406,10 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {...@@ -363,6 +406,10 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
363 }406 }
364407
365 const snapshot = await this.codex.readThread(session.sessionId);408 const snapshot = await this.codex.readThread(session.sessionId);
409 await this.filesystemCheckpointStore.ensureRootCheckpoint({
410 cwd,
411 threadId: snapshot.threadId,
412 });
366 await this.filesystemCheckpointStore.captureCheckpoint({413 await this.filesystemCheckpointStore.captureCheckpoint({
367 cwd,414 cwd,
368 threadId: snapshot.threadId,415 threadId: snapshot.threadId,
...@@ -372,6 +419,54 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {...@@ -372,6 +419,54 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
372 });419 });
373 }420 }
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
375 private maybeCaptureFilesystemCheckpoint(event: ProviderEvent): void {470 private maybeCaptureFilesystemCheckpoint(event: ProviderEvent): void {
376 if (event.kind !== "notification" || event.method !== "turn/completed") {471 if (event.kind !== "notification" || event.method !== "turn/completed") {
377 return;472 return;
...@@ -379,6 +474,11 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {...@@ -379,6 +474,11 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
379474
380 const checkpointCwd = this.sessionCheckpointCwds.get(event.sessionId);475 const checkpointCwd = this.sessionCheckpointCwds.get(event.sessionId);
381 if (!checkpointCwd) {476 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 });
382 return;482 return;
383 }483 }
384484
apps/server/src/wsServer.test.ts+8
...@@ -668,6 +668,14 @@ describe("WebSocket Server", () => {...@@ -668,6 +668,14 @@ describe("WebSocket Server", () => {
668 });668 });
669 expect(revertResponse.result).toBeUndefined();669 expect(revertResponse.result).toBeUndefined();
670 expect(revertResponse.error?.message).toContain("Unknown provider session");670 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");
671 });679 });
672680
673 it("routes terminal RPC methods and broadcasts terminal events", async () => {681 it("routes terminal RPC methods and broadcasts terminal events", async () => {
apps/server/src/wsServer.ts+3
...@@ -313,6 +313,9 @@ export function createServer(options: ServerOptions) {...@@ -313,6 +313,9 @@ export function createServer(options: ServerOptions) {
313 case WS_METHODS.providersListCheckpoints:313 case WS_METHODS.providersListCheckpoints:
314 return providerManager.listCheckpoints(request.params as never);314 return providerManager.listCheckpoints(request.params as never);
315315
316 case WS_METHODS.providersGetCheckpointDiff:
317 return providerManager.getCheckpointDiff(request.params as never);
318
316 case WS_METHODS.providersRevertToCheckpoint:319 case WS_METHODS.providersRevertToCheckpoint:
317 return providerManager.revertToCheckpoint(request.params as never);320 return providerManager.revertToCheckpoint(request.params as never);
318321
apps/web/src/App.tsx+34-3
...@@ -9,10 +9,13 @@ import { StoreProvider, useStore } from "./store";...@@ -9,10 +9,13 @@ import { StoreProvider, useStore } from "./store";
9import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types";9import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types";
10import { onServerWelcome } from "./wsNativeApi";10import { onServerWelcome } from "./wsNativeApi";
11import { useNativeApi } from "./hooks/useNativeApi";11import { useNativeApi } from "./hooks/useNativeApi";
12import { useMediaQuery } from "./hooks/useMediaQuery";
12import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast";13import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast";
14import { Sheet, SheetPopup } from "./components/ui/sheet";
13import { invalidateGitQueries } from "./lib/gitReactQuery";15import { invalidateGitQueries } from "./lib/gitReactQuery";
1416
15const DiffPanel = lazy(() => import("./components/DiffPanel"));17const DiffPanel = lazy(() => import("./components/DiffPanel"));
18const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";
1619
17function EventRouter() {20function EventRouter() {
18 const api = useNativeApi();21 const api = useNativeApi();
...@@ -112,6 +115,7 @@ function AutoProjectBootstrap() {...@@ -112,6 +115,7 @@ function AutoProjectBootstrap() {
112 session: null,115 session: null,
113 messages: [],116 messages: [],
114 events: [],117 events: [],
118 turnDiffSummaries: [],
115 error: null,119 error: null,
116 createdAt: new Date().toISOString(),120 createdAt: new Date().toISOString(),
117 branch: null,121 branch: null,
...@@ -177,7 +181,8 @@ function DesktopProjectBootstrap() {...@@ -177,7 +181,8 @@ function DesktopProjectBootstrap() {
177181
178function Layout() {182function Layout() {
179 const api = useNativeApi();183 const api = useNativeApi();
180 const { state } = useStore();184 const { state, dispatch } = useStore();
185 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);
181186
182 if (!api) {187 if (!api) {
183 return (188 return (
...@@ -196,7 +201,7 @@ function Layout() {...@@ -196,7 +201,7 @@ function Layout() {
196 <DesktopProjectBootstrap />201 <DesktopProjectBootstrap />
197 <Sidebar />202 <Sidebar />
198 <ChatView />203 <ChatView />
199 {state.diffOpen && (204 {state.diffOpen && !shouldUseDiffSheet && (
200 <Suspense205 <Suspense
201 fallback={206 fallback={
202 <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">207 <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() {...@@ -204,9 +209,35 @@ function Layout() {
204 </aside>209 </aside>
205 }210 }
206 >211 >
207 <DiffPanel />212 <DiffPanel mode="inline" />
208 </Suspense>213 </Suspense>
209 )}214 )}
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 )}
210 </div>241 </div>
211 );242 );
212}243}
apps/web/src/components/ChatView.tsx+427-238
...@@ -8,7 +8,6 @@ import {...@@ -8,7 +8,6 @@ import {
8 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,8 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
9 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,9 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
10 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,10 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
11 type ProviderCheckpoint,
12 type ResolvedKeybindingsConfig,11 type ResolvedKeybindingsConfig,
13 type ProviderApprovalDecision,12 type ProviderApprovalDecision,
14 type ProviderSendTurnAttachmentInput,13 type ProviderSendTurnAttachmentInput,
...@@ -50,10 +49,12 @@ import {...@@ -50,10 +49,12 @@ import {
50 resolveModelSlug,49 resolveModelSlug,
51} from "../model-logic";50} from "../model-logic";
52import {51import {
52 countDiffStat,
53 derivePendingApprovals,53 derivePendingApprovals,
54 derivePhase,54 derivePhase,
55 deriveTurnDiffSummaries,55 deriveTurnDiffSummaries,
56 deriveTimelineEntries,56 deriveTimelineEntries,
57 inferCheckpointTurnCountByTurnId,
57 type TurnDiffSummary,58 type TurnDiffSummary,
58 type PendingApproval,59 type PendingApproval,
59 deriveWorkLogEntries,60 deriveWorkLogEntries,
...@@ -87,6 +88,7 @@ import {...@@ -87,6 +88,7 @@ import {
87 CircleAlertIcon,88 CircleAlertIcon,
88 FileIcon,89 FileIcon,
89 FolderIcon,90 FolderIcon,
91 DiffIcon,
90 FolderClosedIcon,92 FolderClosedIcon,
91 InfoIcon,93 InfoIcon,
92 LockIcon,94 LockIcon,
...@@ -348,9 +350,7 @@ export default function ChatView() {...@@ -348,9 +350,7 @@ export default function ChatView() {
348 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);350 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
349 const [isSending, setIsSending] = useState(false);351 const [isSending, setIsSending] = useState(false);
350 const [isConnecting, setIsConnecting] = useState(false);352 const [isConnecting, setIsConnecting] = useState(false);
351 const [isLoadingCheckpoints, setIsLoadingCheckpoints] = useState(false);
352 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);353 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
353 const [checkpoints, setCheckpoints] = useState<ProviderCheckpoint[]>([]);
354 const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING);354 const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING);
355 const [envMode, setEnvMode] = useState<"local" | "worktree">("local");355 const [envMode, setEnvMode] = useState<"local" | "worktree">("local");
356 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);356 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
...@@ -371,10 +371,13 @@ export default function ChatView() {...@@ -371,10 +371,13 @@ export default function ChatView() {
371 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);371 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
372 const dragDepthRef = useRef(0);372 const dragDepthRef = useRef(0);
373 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});373 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
374 const checkpointDiffRequestsRef = useRef(new Set<string>());
375 const checkpointHydrationSessionRequestRef = useRef(new Set<string>());
374376
375 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);377 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
376 const activeThreadId = activeThread?.id ?? null;378 const activeThreadId = activeThread?.id ?? null;
377 const activeSessionId = activeThread?.session?.sessionId;379 const activeSessionId = activeThread?.session?.sessionId;
380 const activeThreadRuntimeId = activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null;
378 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);381 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
379 const selectedModel = resolveModelSlug(382 const selectedModel = resolveModelSlug(
380 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,383 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
...@@ -409,8 +412,11 @@ export default function ChatView() {...@@ -409,8 +412,11 @@ export default function ChatView() {
409 [activeThread?.messages, workLogEntries],412 [activeThread?.messages, workLogEntries],
410 );413 );
411 const turnDiffSummaries = useMemo(414 const turnDiffSummaries = useMemo(
412 () => deriveTurnDiffSummaries(activeThread?.events ?? []),415 () =>
413 [activeThread?.events],416 activeThread?.turnDiffSummaries.length
417 ? activeThread.turnDiffSummaries
418 : deriveTurnDiffSummaries(activeThread?.events ?? []),
419 [activeThread?.events, activeThread?.turnDiffSummaries],
414 );420 );
415 const turnDiffSummaryByAssistantMessageId = useMemo(() => {421 const turnDiffSummaryByAssistantMessageId = useMemo(() => {
416 const byMessageId = new Map<string, TurnDiffSummary>();422 const byMessageId = new Map<string, TurnDiffSummary>();
...@@ -420,6 +426,134 @@ export default function ChatView() {...@@ -420,6 +426,134 @@ export default function ChatView() {
420 }426 }
421 return byMessageId;427 return byMessageId;
422 }, [turnDiffSummaries]);428 }, [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
423 const completionSummary = useMemo(() => {557 const completionSummary = useMemo(() => {
424 if (!activeThread?.latestTurnStartedAt) return null;558 if (!activeThread?.latestTurnStartedAt) return null;
425 if (!activeThread.latestTurnCompletedAt) return null;559 if (!activeThread.latestTurnCompletedAt) return null;
...@@ -475,16 +609,9 @@ export default function ChatView() {...@@ -475,16 +609,9 @@ export default function ChatView() {
475 completionSummary,609 completionSummary,
476 timelineEntries,610 timelineEntries,
477 ]);611 ]);
478 const runtimeSessionConfig =612 const runtimeApprovalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request";
479 state.runtimeMode === "full-access"613 const runtimeSandboxMode =
480 ? ({614 state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write";
481 approvalPolicy: "never",
482 sandboxMode: "danger-full-access",
483 } as const)
484 : ({
485 approvalPolicy: "on-request",
486 sandboxMode: "workspace-write",
487 } as const);
488 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;615 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
489 const composerTrigger = useMemo(616 const composerTrigger = useMemo(
490 () => detectComposerTrigger(prompt, composerCursor),617 () => detectComposerTrigger(prompt, composerCursor),
...@@ -593,12 +720,6 @@ export default function ChatView() {...@@ -593,12 +720,6 @@ export default function ChatView() {
593 );720 );
594 const hasReachedTerminalLimit =721 const hasReachedTerminalLimit =
595 (activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT;722 (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
603 const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => {724 const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => {
604 for (const image of images) {725 for (const image of images) {
...@@ -988,8 +1109,6 @@ export default function ChatView() {...@@ -988,8 +1109,6 @@ export default function ChatView() {
988 }, [composerMenuItems, composerMenuOpen]);1109 }, [composerMenuItems, composerMenuOpen]);
9891110
990 useEffect(() => {1111 useEffect(() => {
991 setCheckpoints([]);
992 setIsLoadingCheckpoints(false);
993 setIsRevertingCheckpoint(false);1112 setIsRevertingCheckpoint(false);
994 }, [activeThread?.id]);1113 }, [activeThread?.id]);
9951114
...@@ -1172,14 +1291,17 @@ export default function ChatView() {...@@ -1172,14 +1291,17 @@ export default function ChatView() {
1172 toggleTerminalVisibility,1291 toggleTerminalVisibility,
1173 ]);1292 ]);
11741293
1175 const setThreadError = (threadId: string | null, error: string | null) => {1294 const setThreadError = useCallback(
1176 if (!threadId) return;1295 (threadId: string | null, error: string | null) => {
1177 dispatch({1296 if (!threadId) return;
1178 type: "SET_ERROR",1297 dispatch({
1179 threadId,1298 type: "SET_ERROR",
1180 error,1299 threadId,
1181 });1300 error,
1182 };1301 });
1302 },
1303 [dispatch],
1304 );
11831305
1184 const addComposerImages = (files: File[]) => {1306 const addComposerImages = (files: File[]) => {
1185 if (!activeThreadId || files.length === 0) return;1307 if (!activeThreadId || files.length === 0) return;
...@@ -1284,134 +1406,169 @@ export default function ChatView() {...@@ -1284,134 +1406,169 @@ export default function ChatView() {
1284 focusComposer();1406 focusComposer();
1285 };1407 };
12861408
1287 const ensureSession = async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => {1409 const ensureSession = useCallback(
1288 if (!api || !activeThread || !activeProject) return null;1410 async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => {
1289 if (activeThread.session && activeThread.session.status !== "closed") {1411 if (!api || !activeThread || !activeProject) return null;
1290 const sessionThreadId = activeThread.session.threadId ?? null;1412 if (activeThread.session && activeThread.session.status !== "closed") {
1291 const continuityState: SessionContinuityState =1413 const sessionThreadId = activeThread.session.threadId ?? null;
1292 activeThread.codexThreadId === null1414 const continuityState: SessionContinuityState =
1293 ? "new"1415 activeThread.codexThreadId === null
1294 : sessionThreadId === activeThread.codexThreadId1416 ? "new"
1295 ? "resumed"1417 : sessionThreadId === activeThread.codexThreadId
1296 : "fallback_new";1418 ? "resumed"
1297 return {1419 : "fallback_new";
1298 sessionId: activeThread.session.sessionId,1420 return {
1299 resolvedThreadId: sessionThreadId,1421 sessionId: activeThread.session.sessionId,
1300 continuityState,1422 resolvedThreadId: sessionThreadId,
1301 } satisfies EnsuredSessionInfo;1423 continuityState,
1302 }1424 } satisfies EnsuredSessionInfo;
13031425 }
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 }
13541426
1355 setIsLoadingCheckpoints(true);1427 const priorCodexThreadId = activeThread.codexThreadId;
1356 setThreadError(activeThread.id, null);1428 setIsConnecting(true);
1357 try {1429 try {
1358 const sessionInfo = await ensureSession();1430 const session = await api.providers.startSession({
1359 if (!sessionInfo) {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 });
1360 return null;1461 return null;
1462 } finally {
1463 setIsConnecting(false);
1361 }1464 }
1362 const result = await api.providers.listCheckpoints({1465 },
1363 sessionId: sessionInfo.sessionId,1466 [activeProject, activeThread, api, dispatch, runtimeApprovalPolicy, runtimeSandboxMode, selectedModel],
1364 });1467 );
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 };
13771468
1378 const onRevertToCheckpoint = async (checkpoint: ProviderCheckpoint) => {1469 useEffect(() => {
1379 if (!api || !activeThread || checkpoint.isCurrent || isRevertingCheckpoint) {1470 if (!api || !activeThreadId || activeSessionId) return;
1380 return;1471 if (phase === "running" || isSending || isConnecting || isRevertingCheckpoint) return;
1381 }1472 const hasThreadIdentity = Boolean(activeThreadRuntimeId);
1382 if (phase === "running" || isSending || isConnecting) {1473 if (!hasThreadIdentity) return;
1383 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");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)) {
1384 return;1489 return;
1385 }1490 }
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);1513 const onRevertToTurnCount = useCallback(
1388 setThreadError(activeThread.id, null);1514 async (turnCount: number) => {
1389 try {1515 if (!api || !activeThread || isRevertingCheckpoint) {
1390 const sessionInfo = await ensureSession();1516 return;
1391 if (!sessionInfo) {1517 }
1518 if (phase === "running" || isSending || isConnecting) {
1519 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
1392 return;1520 return;
1393 }1521 }
1394 const result = await api.providers.revertToCheckpoint({1522
1395 sessionId: sessionInfo.sessionId,1523 setIsRevertingCheckpoint(true);
1396 turnCount: checkpoint.turnCount,1524 setThreadError(activeThread.id, null);
1397 });1525 try {
1398 dispatch({1526 const sessionInfo = await ensureSession();
1399 type: "REVERT_TO_CHECKPOINT",1527 if (!sessionInfo) {
1400 threadId: activeThread.id,1528 return;
1401 sessionId: sessionInfo.sessionId,1529 }
1402 threadRuntimeId: result.threadId,1530 const result = await api.providers.revertToCheckpoint({
1403 messageCount: result.messageCount,1531 sessionId: sessionInfo.sessionId,
1404 });1532 turnCount,
1405 setCheckpoints(result.checkpoints);1533 });
1406 } catch (err) {1534 dispatch({
1407 setThreadError(1535 type: "REVERT_TO_CHECKPOINT",
1408 activeThread.id,1536 threadId: activeThread.id,
1409 err instanceof Error ? err.message : "Failed to revert checkpoint.",1537 sessionId: sessionInfo.sessionId,
1410 );1538 threadRuntimeId: result.threadId,
1411 } finally {1539 turnCount: result.turnCount,
1412 setIsRevertingCheckpoint(false);1540 messageCount: result.messageCount,
1413 }1541 });
1414 };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
1416 const onSend = async (e: FormEvent) => {1573 const onSend = async (e: FormEvent) => {
1417 e.preventDefault();1574 e.preventDefault();
...@@ -1704,8 +1861,7 @@ export default function ChatView() {...@@ -1704,8 +1861,7 @@ export default function ChatView() {
1704 return;1861 return;
1705 }1862 }
17061863
1707 const latestSummary = turnDiffSummaries[0];1864 if (!activeThread) {
1708 if (!activeThread || !latestSummary) {
1709 dispatch({ type: "TOGGLE_DIFF" });1865 dispatch({ type: "TOGGLE_DIFF" });
1710 return;1866 return;
1711 }1867 }
...@@ -1713,10 +1869,8 @@ export default function ChatView() {...@@ -1713,10 +1869,8 @@ export default function ChatView() {
1713 dispatch({1869 dispatch({
1714 type: "OPEN_DIFF",1870 type: "OPEN_DIFF",
1715 threadId: activeThread.id,1871 threadId: activeThread.id,
1716 turnId: latestSummary.turnId,
1717 ...(latestSummary.files[0]?.path ? { filePath: latestSummary.files[0].path } : {}),
1718 });1872 });
1719 }, [activeThread, dispatch, state.diffOpen, turnDiffSummaries]);1873 }, [activeThread, dispatch, state.diffOpen]);
1720 const onOpenTurnDiff = useCallback(1874 const onOpenTurnDiff = useCallback(
1721 (turnId: string, filePath?: string) => {1875 (turnId: string, filePath?: string) => {
1722 if (!activeThread) return;1876 if (!activeThread) return;
...@@ -1729,6 +1883,16 @@ export default function ChatView() {...@@ -1729,6 +1883,16 @@ export default function ChatView() {
1729 },1883 },
1730 [activeThread, dispatch],1884 [activeThread, dispatch],
1731 );1885 );
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
1733 // Empty state: no active thread1897 // Empty state: no active thread
1734 if (!activeThread) {1898 if (!activeThread) {
...@@ -1771,14 +1935,6 @@ export default function ChatView() {...@@ -1771,14 +1935,6 @@ export default function ChatView() {
1771 onAddProjectScript={saveProjectScript}1935 onAddProjectScript={saveProjectScript}
1772 onUpdateProjectScript={updateProjectScript}1936 onUpdateProjectScript={updateProjectScript}
1773 onToggleDiff={onToggleDiff}1937 onToggleDiff={onToggleDiff}
1774 checkpoints={checkpoints}
1775 canCheckpoint={canCheckpoint}
1776 isLoadingCheckpoints={isLoadingCheckpoints}
1777 isRevertingCheckpoint={isRevertingCheckpoint}
1778 onOpenCheckpoints={() => {
1779 void loadCheckpoints();
1780 }}
1781 onRevertToCheckpoint={onRevertToCheckpoint}
1782 />1938 />
1783 </header>1939 </header>
17841940
...@@ -1808,6 +1964,9 @@ export default function ChatView() {...@@ -1808,6 +1964,9 @@ export default function ChatView() {
1808 expandedWorkGroups={expandedWorkGroups}1964 expandedWorkGroups={expandedWorkGroups}
1809 onToggleWorkGroup={onToggleWorkGroup}1965 onToggleWorkGroup={onToggleWorkGroup}
1810 onOpenTurnDiff={onOpenTurnDiff}1966 onOpenTurnDiff={onOpenTurnDiff}
1967 revertTurnCountByUserMessageId={revertTurnCountByUserMessageId}
1968 onRevertUserMessage={onRevertUserMessage}
1969 isRevertingCheckpoint={isRevertingCheckpoint}
1811 onImageExpand={onExpandTimelineImage}1970 onImageExpand={onExpandTimelineImage}
1812 messagesEndRef={messagesEndRef}1971 messagesEndRef={messagesEndRef}
1813 />1972 />
...@@ -2114,12 +2273,6 @@ interface ChatHeaderProps {...@@ -2114,12 +2273,6 @@ interface ChatHeaderProps {
2114 onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>;2273 onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>;
2115 onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>;2274 onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>;
2116 onToggleDiff: () => void;2275 onToggleDiff: () => void;
2117 checkpoints: ProviderCheckpoint[];
2118 canCheckpoint: boolean;
2119 isLoadingCheckpoints: boolean;
2120 isRevertingCheckpoint: boolean;
2121 onOpenCheckpoints: () => void;
2122 onRevertToCheckpoint: (checkpoint: ProviderCheckpoint) => void;
2123}2276}
21242277
2125const ChatHeader = memo(function ChatHeader({2278const ChatHeader = memo(function ChatHeader({
...@@ -2135,12 +2288,6 @@ const ChatHeader = memo(function ChatHeader({...@@ -2135,12 +2288,6 @@ const ChatHeader = memo(function ChatHeader({
2135 onAddProjectScript,2288 onAddProjectScript,
2136 onUpdateProjectScript,2289 onUpdateProjectScript,
2137 onToggleDiff,2290 onToggleDiff,
2138 checkpoints,
2139 canCheckpoint,
2140 isLoadingCheckpoints,
2141 isRevertingCheckpoint,
2142 onOpenCheckpoints,
2143 onRevertToCheckpoint,
2144}: ChatHeaderProps) {2291}: ChatHeaderProps) {
2145 return (2292 return (
2146 <>2293 <>
...@@ -2163,56 +2310,14 @@ const ChatHeader = memo(function ChatHeader({...@@ -2163,56 +2310,14 @@ const ChatHeader = memo(function ChatHeader({
2163 )}2310 )}
2164 {activeProjectName && <OpenInPicker keybindings={keybindings} />}2311 {activeProjectName && <OpenInPicker keybindings={keybindings} />}
2165 {activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />}2312 {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>
2206 <Button2313 <Button
2207 size="xs"2314 aria-label="Toggle diff panel"
2208 variant="ghost"2315 size="icon-xs"
2209 className={cn(2316 variant="outline"
2210 "text-muted-foreground/70 hover:text-foreground/80",2317 className={cn(diffOpen && "bg-accent text-accent-foreground")}
2211 diffOpen && "bg-accent text-accent-foreground",
2212 )}
2213 onClick={onToggleDiff}2318 onClick={onToggleDiff}
2214 >2319 >
2215 Diff2320 <DiffIcon />
2216 </Button>2321 </Button>
2217 </div>2322 </div>
2218 </>2323 </>
...@@ -2317,6 +2422,9 @@ interface MessagesTimelineProps {...@@ -2317,6 +2422,9 @@ interface MessagesTimelineProps {
2317 expandedWorkGroups: Record<string, boolean>;2422 expandedWorkGroups: Record<string, boolean>;
2318 onToggleWorkGroup: (groupId: string) => void;2423 onToggleWorkGroup: (groupId: string) => void;
2319 onOpenTurnDiff: (turnId: string, filePath?: string) => void;2424 onOpenTurnDiff: (turnId: string, filePath?: string) => void;
2425 revertTurnCountByUserMessageId: Map<string, number>;
2426 onRevertUserMessage: (messageId: string) => void;
2427 isRevertingCheckpoint: boolean;
2320 onImageExpand: (image: ExpandedImagePreview) => void;2428 onImageExpand: (image: ExpandedImagePreview) => void;
2321 messagesEndRef: RefObject<HTMLDivElement | null>;2429 messagesEndRef: RefObject<HTMLDivElement | null>;
2322}2430}
...@@ -2333,6 +2441,9 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2333,6 +2441,9 @@ const MessagesTimeline = memo(function MessagesTimeline({
2333 expandedWorkGroups,2441 expandedWorkGroups,
2334 onToggleWorkGroup,2442 onToggleWorkGroup,
2335 onOpenTurnDiff,2443 onOpenTurnDiff,
2444 revertTurnCountByUserMessageId,
2445 onRevertUserMessage,
2446 isRevertingCheckpoint,
2336 onImageExpand,2447 onImageExpand,
2337 messagesEndRef,2448 messagesEndRef,
2338}: MessagesTimelineProps) {2449}: MessagesTimelineProps) {
...@@ -2433,6 +2544,7 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2433,6 +2544,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
24332544
2434 if (timelineEntry.message.role === "user") {2545 if (timelineEntry.message.role === "user") {
2435 const userImages = timelineEntry.message.attachments ?? [];2546 const userImages = timelineEntry.message.attachments ?? [];
2547 const canRevertAgentWork = revertTurnCountByUserMessageId.has(timelineEntry.message.id);
2436 return (2548 return (
2437 <Fragment key={timelineEntry.id}>2549 <Fragment key={timelineEntry.id}>
2438 <div className="flex justify-end">2550 <div className="flex justify-end">
...@@ -2467,9 +2579,22 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2467,9 +2579,22 @@ const MessagesTimeline = memo(function MessagesTimeline({
2467 {timelineEntry.message.text}2579 {timelineEntry.message.text}
2468 </pre>2580 </pre>
2469 )}2581 )}
2470 <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30">2582 <div className="mt-1.5 flex items-center justify-end gap-2">
2471 {formatTimestamp(timelineEntry.message.createdAt)}2583 {canRevertAgentWork && (
2472 </p>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>
2473 </div>2598 </div>
2474 </div>2599 </div>
2475 </Fragment>2600 </Fragment>
...@@ -2497,11 +2622,45 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2497,11 +2622,45 @@ const MessagesTimeline = memo(function MessagesTimeline({
2497 {(() => {2622 {(() => {
2498 const turnSummary = turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id);2623 const turnSummary = turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id);
2499 if (!turnSummary) return null;2624 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);
2500 return (2650 return (
2501 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">2651 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
2502 <div className="mb-1.5 flex items-center justify-between gap-2">2652 <div className="mb-1.5 flex items-center justify-between gap-2">
2503 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">2653 <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 )}
2505 </p>2664 </p>
2506 <Button2665 <Button
2507 type="button"2666 type="button"
...@@ -2512,18 +2671,48 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2512,18 +2671,48 @@ const MessagesTimeline = memo(function MessagesTimeline({
2512 View diff2671 View diff
2513 </Button>2672 </Button>
2514 </div>2673 </div>
2515 <div className="flex flex-wrap gap-1.5">2674 {isCheckpointDiffLoading && (
2516 {turnSummary.files.map((file) => (2675 <p className="mb-1.5 text-[11px] text-muted-foreground/70">
2517 <button2676 Loading checkpoint diff...
2518 key={`${turnSummary.turnId}:${file.path}`}2677 </p>
2519 type="button"2678 )}
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"2679 {turnSummary.files.length > 0 ? (
2521 onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)}2680 <div className="flex flex-wrap gap-1.5">
2522 >2681 {turnSummary.files.map((file) => (
2523 {file.path}2682 <button
2524 </button>2683 key={`${turnSummary.turnId}:${file.path}`}
2525 ))}2684 type="button"
2526 </div>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}
2527 </div>2716 </div>
2528 );2717 );
2529 })()}2718 })()}
apps/web/src/components/DiffPanel.tsx+475-90
...@@ -1,62 +1,473 @@...@@ -1,62 +1,473 @@
1import { PatchDiff } from "@pierre/diffs/react";1import { parsePatchFiles } from "@pierre/diffs";
2import { XIcon } from "lucide-react";2import { FileDiff, type FileDiffMetadata } from "@pierre/diffs/react";
3import { useMemo } from "react";3import { Columns2Icon, Rows3Icon, XIcon } from "lucide-react";
4import { useEffect, useMemo, useRef, useState } from "react";
4import { isElectron } from "../env";5import { isElectron } from "../env";
5import { deriveTurnDiffSummaries, formatTimestamp } from "../session-logic";6import { useNativeApi } from "../hooks/useNativeApi";
7import { useTheme } from "../hooks/useTheme";
8import {
9 deriveTurnDiffSummaries,
10 formatTimestamp,
11 inferCheckpointTurnCountByTurnId,
12} from "../session-logic";
6import { useStore } from "../store";13import { useStore } from "../store";
7import { Button } from "./ui/button";14import { Button } from "./ui/button";
15import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
8import { cn } from "~/lib/utils";16import { cn } from "~/lib/utils";
917
10export default function DiffPanel() {18type DiffRenderMode = "stacked" | "split";
19
20type RenderablePatch =
21 | {
22 kind: "files";
23 files: FileDiffMetadata[];
24 }
25 | {
26 kind: "raw";
27 text: string;
28 reason: string;
29 };
30
31function 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
57function 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
65function buildCheckpointDiffKey(
66 sessionId: string,
67 fromTurnCount: number,
68 toTurnCount: number,
69): string {
70 return `${sessionId}:${fromTurnCount}-${toTurnCount}`;
71}
72
73interface DiffPanelProps {
74 mode?: "inline" | "sheet";
75}
76
77export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
78 const api = useNativeApi();
79 const { resolvedTheme } = useTheme();
11 const { state, dispatch } = useStore();80 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);
12 const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId);86 const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId);
13 const turnDiffSummaries = useMemo(87 const turnDiffSummaries = useMemo(
14 () => deriveTurnDiffSummaries(activeThread?.events ?? []),88 () =>
15 [activeThread?.events],89 activeThread?.turnDiffSummaries.length
90 ? activeThread.turnDiffSummaries
91 : deriveTurnDiffSummaries(activeThread?.events ?? []),
92 [activeThread?.events, activeThread?.turnDiffSummaries],
16 );93 );
94 const inferredCheckpointTurnCountByTurnId = useMemo(
95 () => inferCheckpointTurnCountByTurnId(turnDiffSummaries),
96 [turnDiffSummaries],
97 );
98
99 useEffect(() => {
100 setCheckpointDiffByKey({});
101 setCheckpointDiffError(null);
102 }, [activeThread?.id]);
17103
18 const canApplyStoredTarget = Boolean(activeThread && state.diffThreadId === activeThread.id);104 const canApplyStoredTarget = Boolean(activeThread && state.diffThreadId === activeThread.id);
19 const selectedTurnId = canApplyStoredTarget ? state.diffTurnId : null;105 const selectedTurnId = canApplyStoredTarget ? state.diffTurnId : null;
20 const selectedFilePath = canApplyStoredTarget ? state.diffFilePath : null;106 const selectedFilePath =
107 canApplyStoredTarget && selectedTurnId !== null ? state.diffFilePath : null;
21 const selectedTurn =108 const selectedTurn =
22 turnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? turnDiffSummaries[0];109 selectedTurnId === null
23 const selectedFile =110 ? undefined
24 selectedTurn?.files.find((file) => file.path === selectedFilePath) ?? selectedTurn?.files[0];111 : (turnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ??
25 const selectedPatch = selectedFile?.diff ?? selectedTurn?.unifiedDiff;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
27 const selectTurn = (turnId: string) => {338 const selectTurn = (turnId: string) => {
28 if (!activeThread) return;339 if (!activeThread) return;
29 const turn = turnDiffSummaries.find((summary) => summary.turnId === turnId);
30 dispatch({340 dispatch({
31 type: "SET_DIFF_TARGET",341 type: "SET_DIFF_TARGET",
32 threadId: activeThread.id,342 threadId: activeThread.id,
33 turnId,343 turnId,
34 ...(turn?.files[0]?.path ? { filePath: turn.files[0].path } : {}),
35 });344 });
36 };345 };
37346 const selectWholeConversation = () => {
38 const selectFile = (filePath: string) => {347 if (!activeThread) return;
39 if (!activeThread || !selectedTurn) return;
40 dispatch({348 dispatch({
41 type: "SET_DIFF_TARGET",349 type: "SET_DIFF_TARGET",
42 threadId: activeThread.id,350 threadId: activeThread.id,
43 turnId: selectedTurn.turnId,
44 filePath,
45 });351 });
46 };352 };
47353
354 const shouldUseDragRegion = isElectron && mode === "inline";
355
48 return (356 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 >
50 <div365 <div
51 className={cn(366 className={cn(
52 "flex items-center justify-between border-b border-border px-4",367 "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",
54 )}369 )}
55 >370 >
56 <h3 className="text-xs font-medium text-foreground">Turn diffs</h3>371 <div className="flex gap-1.5 overflow-x-auto pb-0.5">
57 <Button type="button" size="icon-xs" variant="ghost" onClick={() => dispatch({ type: "CLOSE_DIFF" })}>372 <button type="button" className="shrink-0 rounded-md" onClick={selectWholeConversation}>
58 <XIcon />373 <div
59 </Button>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>
60 </div>471 </div>
61472
62 {!activeThread ? (473 {!activeThread ? (
...@@ -65,78 +476,52 @@ export default function DiffPanel() {...@@ -65,78 +476,52 @@ export default function DiffPanel() {
65 </div>476 </div>
66 ) : turnDiffSummaries.length === 0 ? (477 ) : turnDiffSummaries.length === 0 ? (
67 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">478 <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.
69 </div>480 </div>
70 ) : (481 ) : (
71 <>482 <>
72 <div className="border-b border-border px-3 py-2.5">483 <div ref={patchViewportRef} className="min-w-0 flex-1 overflow-auto px-3 py-2">
73 <p className="mb-1.5 text-[10px] uppercase tracking-[0.12em] text-muted-foreground/60">484 {!canApplyStoredTarget && state.diffThreadId && (
74 Turns485 <p className="mb-2 text-[11px] text-muted-foreground/65">
75 </p>486 Showing diffs for the active thread.
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
101 </p>487 </p>
102 <div className="space-y-1 overflow-y-auto">488 )}
103 {selectedTurn?.files.map((file) => (489 {checkpointDiffError && !renderablePatch && (
104 <button490 <p className="mb-2 text-[11px] text-red-500/80">{checkpointDiffError}</p>
105 key={`${selectedTurn.turnId}:${file.path}`}491 )}
106 type="button"492 {!renderablePatch ? (
107 className={cn(493 <div className="flex h-full items-center justify-center text-xs text-muted-foreground/70">
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",494 {isLoadingCheckpointDiff
109 selectedFile?.path === file.path495 ? "Loading checkpoint diff..."
110 ? "border-border bg-accent text-accent-foreground"496 : "No patch available for this selection."}
111 : "border-border/60 bg-background/60 text-muted-foreground/85 hover:border-border hover:text-foreground/90",497 </div>
112 )}498 ) : renderablePatch.kind === "files" ? (
113 onClick={() => selectFile(file.path)}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"
114 >505 >
115 <span className="truncate">{file.path}</span>506 <FileDiff
116 {file.kind && (507 fileDiff={fileDiff}
117 <span className="rounded-sm border border-border/60 px-1 py-0.5 text-[9px] uppercase tracking-[0.08em]">508 options={{
118 {file.kind}509 diffStyle: diffRenderMode === "split" ? "split" : "unified",
119 </span>510 lineDiffType: "none",
120 )}511 themeType: resolvedTheme,
121 </button>512 }}
513 />
514 </div>
122 ))}515 ))}
123 </div>516 </div>
124 </div>517 ) : (
125518 <div className="space-y-2">
126 <div className="min-w-0 flex-1 overflow-auto px-3 py-2">519 <p className="text-[11px] text-muted-foreground/75">{renderablePatch.reason}</p>
127 {!canApplyStoredTarget && state.diffThreadId && (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">
128 <p className="mb-2 text-[11px] text-muted-foreground/65">521 {renderablePatch.text}
129 Showing diffs for the active thread.522 </pre>
130 </p>523 </div>
131 )}524 )}
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>
140 </div>525 </div>
141 </>526 </>
142 )}527 )}
apps/web/src/components/Sidebar.tsx+1
...@@ -170,6 +170,7 @@ export default function Sidebar() {...@@ -170,6 +170,7 @@ export default function Sidebar() {
170 session: null,170 session: null,
171 messages: [],171 messages: [],
172 events: [],172 events: [],
173 turnDiffSummaries: [],
173 error: null,174 error: null,
174 createdAt: new Date().toISOString(),175 createdAt: new Date().toISOString(),
175 branch: options?.branch ?? null,176 branch: options?.branch ?? null,
apps/web/src/hooks/useMediaQuery.ts created+27
...@@ -0,0 +1,27 @@
1import { useEffect, useState } from "react";
2
3function getMediaQueryMatch(query: string): boolean {
4 if (typeof window === "undefined") {
5 return false;
6 }
7 return window.matchMedia(query).matches;
8}
9
10export 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
...@@ -263,6 +263,68 @@ describe("hydratePersistedState", () => {...@@ -263,6 +263,68 @@ describe("hydratePersistedState", () => {
263 ]);263 ]);
264 expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`);264 expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`);
265 });265 });
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 });
266});328});
267329
268describe("toPersistedState", () => {330describe("toPersistedState", () => {
...@@ -309,6 +371,25 @@ describe("toPersistedState", () => {...@@ -309,6 +371,25 @@ describe("toPersistedState", () => {
309 lastVisitedAt: "2026-02-08T10:02:00.000Z",371 lastVisitedAt: "2026-02-08T10:02:00.000Z",
310 branch: null,372 branch: null,
311 worktreePath: null,373 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 ],
312 };393 };
313394
314 const persisted = toPersistedState({395 const persisted = toPersistedState({
...@@ -366,6 +447,23 @@ describe("toPersistedState", () => {...@@ -366,6 +447,23 @@ describe("toPersistedState", () => {
366 lastVisitedAt: thread.lastVisitedAt,447 lastVisitedAt: thread.lastVisitedAt,
367 branch: null,448 branch: null,
368 worktreePath: null,449 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 ],
369 });467 });
370 const persistedThread = persisted.threads[0];468 const persistedThread = persisted.threads[0];
371 expect(persistedThread).toBeDefined();469 expect(persistedThread).toBeDefined();
apps/web/src/persistenceSchema.ts+50
...@@ -47,6 +47,23 @@ const persistedTerminalGroupSchema = z.object({...@@ -47,6 +47,23 @@ const persistedTerminalGroupSchema = z.object({
47 terminalIds: z.array(z.string().trim().min(1)),47 terminalIds: z.array(z.string().trim().min(1)),
48});48});
4949
50const 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
57const 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
50const persistedThreadSchema = z.object({67const persistedThreadSchema = z.object({
51 id: z.string().min(1),68 id: z.string().min(1),
52 codexThreadId: z.string().min(1).nullable().default(null),69 codexThreadId: z.string().min(1).nullable().default(null),
...@@ -67,6 +84,7 @@ const persistedThreadSchema = z.object({...@@ -67,6 +84,7 @@ const persistedThreadSchema = z.object({
67 lastVisitedAt: z.string().min(1).optional(),84 lastVisitedAt: z.string().min(1).optional(),
68 branch: z.string().min(1).nullable().optional(),85 branch: z.string().min(1).nullable().optional(),
69 worktreePath: z.string().min(1).nullable().optional(),86 worktreePath: z.string().min(1).nullable().optional(),
87 turnDiffSummaries: z.array(persistedTurnDiffSummarySchema).default([]),
70});88});
7189
72const persistedStateBodySchema = z.object({90const persistedStateBodySchema = z.object({
...@@ -273,6 +291,22 @@ function hydrateThread(...@@ -273,6 +291,22 @@ function hydrateThread(
273 lastVisitedAt: thread.lastVisitedAt,291 lastVisitedAt: thread.lastVisitedAt,
274 branch: thread.branch ?? null,292 branch: thread.branch ?? null,
275 worktreePath: thread.worktreePath ?? null,293 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 })),
276 };310 };
277}311}
278312
...@@ -353,6 +387,22 @@ export function toPersistedState(...@@ -353,6 +387,22 @@ export function toPersistedState(
353 ...(thread.lastVisitedAt ? { lastVisitedAt: thread.lastVisitedAt } : {}),387 ...(thread.lastVisitedAt ? { lastVisitedAt: thread.lastVisitedAt } : {}),
354 branch: thread.branch,388 branch: thread.branch,
355 worktreePath: thread.worktreePath,389 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 })),
356 })),406 })),
357 activeThreadId: state.activeThreadId,407 activeThreadId: state.activeThreadId,
358 runtimeMode: state.runtimeMode,408 runtimeMode: state.runtimeMode,
apps/web/src/session-logic.test.ts+53-3
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 type WorkLogEntry,5 type WorkLogEntry,
6 applyEventToMessages,6 applyEventToMessages,
7 derivePendingApprovals,7 derivePendingApprovals,
8 deriveTurnDiffFilesFromUnifiedDiff,
8 deriveTurnDiffSummaries,9 deriveTurnDiffSummaries,
9 deriveTimelineEntries,10 deriveTimelineEntries,
10 deriveWorkLogEntries,11 deriveWorkLogEntries,
...@@ -395,7 +396,7 @@ describe("deriveWorkLogEntries", () => {...@@ -395,7 +396,7 @@ describe("deriveWorkLogEntries", () => {
395});396});
396397
397describe("deriveTurnDiffSummaries", () => {398describe("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", () => {
399 const summaries = deriveTurnDiffSummaries([400 const summaries = deriveTurnDiffSummaries([
400 makeEvent({401 makeEvent({
401 id: "evt-turn-start",402 id: "evt-turn-start",
...@@ -492,8 +493,57 @@ describe("deriveTurnDiffSummaries", () => {...@@ -492,8 +493,57 @@ describe("deriveTurnDiffSummaries", () => {
492 expect(summaries).toHaveLength(1);493 expect(summaries).toHaveLength(1);
493 expect(summaries[0]?.turnId).toBe("turn-1");494 expect(summaries[0]?.turnId).toBe("turn-1");
494 expect(summaries[0]?.assistantMessageId).toBe("msg-1");495 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).toEqual([]);
496 expect(summaries[0]?.files[0]?.kind).toBe("modified");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
527describe("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);
497 });547 });
498});548});
499549
apps/web/src/session-logic.ts+65-114
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import type { NativeApi, ProviderEvent, ProviderKind, ProviderSession } from "@t3tools/contracts";1import type { NativeApi, ProviderEvent, ProviderKind, ProviderSession } from "@t3tools/contracts";
2import type { ChatMessage, SessionPhase } from "./types";2import type { ChatMessage, SessionPhase, TurnDiffFileChange, TurnDiffSummary } from "./types";
3import { createWsNativeApi } from "./wsNativeApi";3import { createWsNativeApi } from "./wsNativeApi";
44
5export const PROVIDER_OPTIONS: Array<{5export const PROVIDER_OPTIONS: Array<{
...@@ -99,20 +99,7 @@ export type TimelineEntry =...@@ -99,20 +99,7 @@ export type TimelineEntry =
99 entry: WorkLogEntry;99 entry: WorkLogEntry;
100 };100 };
101101
102export interface TurnDiffFileChange {102export type { TurnDiffFileChange, TurnDiffSummary } from "./types";
103 path: string;
104 kind?: string;
105 diff?: string;
106}
107
108export interface TurnDiffSummary {
109 turnId: string;
110 completedAt: string;
111 status?: string;
112 files: TurnDiffFileChange[];
113 unifiedDiff?: string;
114 assistantMessageId?: string;
115}
116103
117function normalizeDetail(value: string | undefined): string | undefined {104function normalizeDetail(value: string | undefined): string | undefined {
118 if (!value) return undefined;105 if (!value) return undefined;
...@@ -120,10 +107,6 @@ function normalizeDetail(value: string | undefined): string | undefined {...@@ -120,10 +107,6 @@ function normalizeDetail(value: string | undefined): string | undefined {
120 return trimmed.length > 0 ? trimmed : undefined;107 return trimmed.length > 0 ? trimmed : undefined;
121}108}
122109
123function asArray(value: unknown): unknown[] | undefined {
124 return Array.isArray(value) ? value : undefined;
125}
126
127function approvalDetail(event: ProviderEvent): string | undefined {110function approvalDetail(event: ProviderEvent): string | undefined {
128 const payload = asObject(event.payload);111 const payload = asObject(event.payload);
129 const command = asString(payload?.command);112 const command = asString(payload?.command);
...@@ -457,19 +440,11 @@ export function deriveWorkLogEntries(...@@ -457,19 +440,11 @@ export function deriveWorkLogEntries(
457 return entries;440 return entries;
458}441}
459442
460interface MutableTurnDiffFileChange {
461 path: string;
462 kind: string | undefined;
463 diff: string | undefined;
464}
465
466interface MutableTurnDiffSummary {443interface MutableTurnDiffSummary {
467 turnId: string;444 turnId: string;
468 completedAt: string | undefined;445 completedAt: string | undefined;
469 status: string | undefined;446 status: string | undefined;
470 unifiedDiff: string | undefined;
471 assistantMessageId: string | undefined;447 assistantMessageId: string | undefined;
472 filesByPath: Map<string, MutableTurnDiffFileChange>;
473}448}
474449
475function parsePathFromDiff(diff: string): string | undefined {450function parsePathFromDiff(diff: string): string | undefined {
...@@ -485,7 +460,7 @@ function parsePathFromDiff(diff: string): string | undefined {...@@ -485,7 +460,7 @@ function parsePathFromDiff(diff: string): string | undefined {
485 return direct[1];460 return direct[1];
486}461}
487462
488function splitUnifiedDiffByFile(diff: string): Map<string, string> {463export function splitUnifiedDiffByFile(diff: string): Map<string, string> {
489 const normalized = diff.replace(/\r\n/g, "\n");464 const normalized = diff.replace(/\r\n/g, "\n");
490 const byPath = new Map<string, string>();465 const byPath = new Map<string, string>();
491 const headerMatches = [...normalized.matchAll(/^diff --git a\/(.+) b\/(.+)$/gm)];466 const headerMatches = [...normalized.matchAll(/^diff --git a\/(.+) b\/(.+)$/gm)];
...@@ -512,38 +487,71 @@ function splitUnifiedDiffByFile(diff: string): Map<string, string> {...@@ -512,38 +487,71 @@ function splitUnifiedDiffByFile(diff: string): Map<string, string> {
512 return byPath;487 return byPath;
513}488}
514489
515function parseFileChangeEntriesFromEvent(event: ProviderEvent): TurnDiffFileChange[] {490export function deriveTurnDiffFilesFromUnifiedDiff(diff: string): TurnDiffFileChange[] {
516 if (event.method !== "item/started" && event.method !== "item/completed") {491 const fileDiffsByPath = splitUnifiedDiffByFile(diff);
517 return [];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
505export function inferCheckpointTurnCountByTurnId(
506 summaries: TurnDiffSummary[],
507): Record<string, number> {
508 if (summaries.length === 0) {
509 return {};
518 }510 }
519511
520 const payload = asObject(event.payload);512 const sorted = [...summaries].toSorted((a, b) => {
521 const item = asObject(payload?.item);513 const aTime = Date.parse(a.completedAt);
522 if (asString(item?.type) !== "fileChange") {514 const bTime = Date.parse(b.completedAt);
523 return [];515 if (Number.isNaN(aTime) || Number.isNaN(bTime)) {
524 }516 return a.completedAt.localeCompare(b.completedAt);
525517 }
526 const changes = asArray(item?.changes);518 return aTime - bTime;
527 if (!changes || changes.length === 0) {519 });
528 return [];520
529 }521 const next: Record<string, number> = {};
530522 let fallbackTurnCount = 1;
531 const files: TurnDiffFileChange[] = [];523 for (const summary of sorted) {
532 for (const rawChange of changes) {524 if (typeof summary.checkpointTurnCount === "number") {
533 const change = asObject(rawChange);525 next[summary.turnId] = summary.checkpointTurnCount;
534 if (!change) continue;526 fallbackTurnCount = Math.max(fallbackTurnCount, summary.checkpointTurnCount + 1);
535 const diff = normalizeDetail(asString(change.diff));527 continue;
536 const path = normalizeDetail(asString(change.path)) ?? (diff ? parsePathFromDiff(diff) : undefined);528 }
537 if (!path) continue;529 next[summary.turnId] = fallbackTurnCount;
538 const kind = normalizeDetail(asString(change.kind));530 fallbackTurnCount += 1;
539 files.push({531 }
540 path,532
541 ...(kind ? { kind } : {}),533 return next;
542 ...(diff ? { diff } : {}),534}
543 });535
536export 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 }
544 }552 }
545553
546 return files;554 return { additions, deletions };
547}555}
548556
549export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummary[] {557export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummary[] {
...@@ -557,9 +565,7 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar...@@ -557,9 +565,7 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar
557 turnId,565 turnId,
558 completedAt: undefined,566 completedAt: undefined,
559 status: undefined,567 status: undefined,
560 unifiedDiff: undefined,
561 assistantMessageId: undefined,568 assistantMessageId: undefined,
562 filesByPath: new Map(),
563 };569 };
564 byTurnId.set(turnId, next);570 byTurnId.set(turnId, next);
565 return next;571 return next;
...@@ -577,12 +583,6 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar...@@ -577,12 +583,6 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar
577 continue;583 continue;
578 }584 }
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
586 if (event.method === "item/completed") {586 if (event.method === "item/completed") {
587 const item = asObject(asObject(event.payload)?.item);587 const item = asObject(asObject(event.payload)?.item);
588 if (asString(item?.type) === "agentMessage") {588 if (asString(item?.type) === "agentMessage") {
...@@ -592,66 +592,17 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar...@@ -592,66 +592,17 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar
592 }592 }
593 }593 }
594 }594 }
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 }
612 }595 }
613596
614 const summaries: TurnDiffSummary[] = [];597 const summaries: TurnDiffSummary[] = [];
615 for (const summary of byTurnId.values()) {598 for (const summary of byTurnId.values()) {
616 if (!summary.completedAt) continue;599 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
649 summaries.push({601 summaries.push({
650 turnId: summary.turnId,602 turnId: summary.turnId,
651 completedAt: summary.completedAt,603 completedAt: summary.completedAt,
652 ...(summary.status ? { status: summary.status } : {}),604 ...(summary.status ? { status: summary.status } : {}),
653 files,605 files: [],
654 ...(summary.unifiedDiff ? { unifiedDiff: summary.unifiedDiff } : {}),
655 ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}),606 ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}),
656 });607 });
657 }608 }
apps/web/src/store.test.ts+139
...@@ -57,6 +57,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {...@@ -57,6 +57,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
57 session: makeSession(),57 session: makeSession(),
58 messages: [],58 messages: [],
59 events: [],59 events: [],
60 turnDiffSummaries: [],
60 error: null,61 error: null,
61 createdAt: "2026-02-09T00:00:00.000Z",62 createdAt: "2026-02-09T00:00:00.000Z",
62 branch: null,63 branch: null,
...@@ -517,6 +518,57 @@ describe("store reducer thread continuity", () => {...@@ -517,6 +518,57 @@ describe("store reducer thread continuity", () => {
517 expect(next.threads[0]?.session?.threadId).toBe("thr_new");518 expect(next.threads[0]?.session?.threadId).toBe("thr_new");
518 });519 });
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
520 it("reconciles project ids by cwd when syncing backend projects", () => {572 it("reconciles project ids by cwd when syncing backend projects", () => {
521 const state: AppState = {573 const state: AppState = {
522 projects: [574 projects: [
...@@ -781,6 +833,20 @@ describe("store reducer thread continuity", () => {...@@ -781,6 +833,20 @@ describe("store reducer thread continuity", () => {
781 turnId: "turn-live",833 turnId: "turn-live",
782 }),834 }),
783 ],835 ],
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 ],
784 error: "temporary failure",850 error: "temporary failure",
785 latestTurnId: "turn-live",851 latestTurnId: "turn-live",
786 latestTurnStartedAt: "2026-02-08T10:00:03.000Z",852 latestTurnStartedAt: "2026-02-08T10:00:03.000Z",
...@@ -792,6 +858,7 @@ describe("store reducer thread continuity", () => {...@@ -792,6 +858,7 @@ describe("store reducer thread continuity", () => {
792 threadId: "thread-local-1",858 threadId: "thread-local-1",
793 sessionId: "sess-1",859 sessionId: "sess-1",
794 threadRuntimeId: "thr_after",860 threadRuntimeId: "thr_after",
861 turnCount: 1,
795 messageCount: 2,862 messageCount: 2,
796 });863 });
797864
...@@ -803,5 +870,77 @@ describe("store reducer thread continuity", () => {...@@ -803,5 +870,77 @@ describe("store reducer thread continuity", () => {
803 expect(next.threads[0]?.session?.activeTurnId).toBeUndefined();870 expect(next.threads[0]?.session?.activeTurnId).toBeUndefined();
804 expect(next.threads[0]?.session?.threadId).toBe("thr_after");871 expect(next.threads[0]?.session?.threadId).toBe("thr_after");
805 expect(next.threads[0]?.latestTurnId).toBeUndefined();872 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);
806 });945 });
807});946});
apps/web/src/store.ts+201-22
...@@ -11,7 +11,15 @@ import {...@@ -11,7 +11,15 @@ import {
11import { type ProviderEvent, type ProviderSession, type TerminalEvent, normalizeProjectScripts } from "@t3tools/contracts";11import { type ProviderEvent, type ProviderSession, type TerminalEvent, normalizeProjectScripts } from "@t3tools/contracts";
12import { resolveModelSlug } from "./model-logic";12import { resolveModelSlug } from "./model-logic";
13import { hydratePersistedState, toPersistedState } from "./persistenceSchema";13import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
14import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic";14import {
15 applyEventToMessages,
16 asObject,
17 asString,
18 deriveTurnDiffFilesFromUnifiedDiff,
19 deriveTurnDiffSummaries,
20 inferCheckpointTurnCountByTurnId,
21 evolveSession,
22} from "./session-logic";
15import {23import {
16 type ChatAttachment,24 type ChatAttachment,
17 DEFAULT_THREAD_TERMINAL_ID,25 DEFAULT_THREAD_TERMINAL_ID,
...@@ -77,8 +85,19 @@ type Action =...@@ -77,8 +85,19 @@ type Action =
77 threadId: string;85 threadId: string;
78 sessionId: string;86 sessionId: string;
79 threadRuntimeId: string;87 threadRuntimeId: string;
88 turnCount: number;
80 messageCount: number;89 messageCount: number;
81 }90 }
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 }
82 | {101 | {
83 type: "SET_THREAD_BRANCH";102 type: "SET_THREAD_BRANCH";
84 threadId: string;103 threadId: string;
...@@ -179,6 +198,77 @@ function updateThread(...@@ -179,6 +198,77 @@ function updateThread(
179 return threads.map((t) => (t.id === threadId ? updater(t) : t));198 return threads.map((t) => (t.id === threadId ? updater(t) : t));
180}199}
181200
201function 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
182function normalizeTerminalIds(terminalIds: string[]): string[] {272function normalizeTerminalIds(terminalIds: string[]): string[] {
183 const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0);273 const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0);
184 const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT);274 const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT);
...@@ -554,6 +644,7 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -554,6 +644,7 @@ export function reducer(state: AppState, action: Action): AppState {
554 ...action.thread,644 ...action.thread,
555 model: resolveModelSlug(action.thread.model),645 model: resolveModelSlug(action.thread.model),
556 lastVisitedAt: action.thread.lastVisitedAt ?? action.thread.createdAt,646 lastVisitedAt: action.thread.lastVisitedAt ?? action.thread.createdAt,
647 turnDiffSummaries: action.thread.turnDiffSummaries ?? [],
557 });648 });
558 return {649 return {
559 ...state,650 ...state,
...@@ -800,27 +891,32 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -800,27 +891,32 @@ export function reducer(state: AppState, action: Action): AppState {
800891
801 return {892 return {
802 ...state,893 ...state,
803 threads: updateThread(state.threads, target.id, (t) => ({894 threads: updateThread(state.threads, target.id, (t) => {
804 ...t,895 const nextEvents = [event, ...t.events];
805 ...(() => {896 const derivedTurnDiffSummaries = deriveTurnDiffSummaries(nextEvents);
806 const eventThreadId = getEventThreadId(event);897 const turnDiffSummaries = mergeTurnDiffSummaries(
807 const shouldRebindIdentity =898 t.turnDiffSummaries,
808 event.method === "thread/started" && t.session?.status === "connecting";899 derivedTurnDiffSummaries,
809 return {900 );
810 codexThreadId: shouldRebindIdentity901 const eventThreadId = getEventThreadId(event);
811 ? (eventThreadId ?? t.codexThreadId)902 const shouldRebindIdentity =
812 : (t.codexThreadId ?? eventThreadId ?? null),903 event.method === "thread/started" && t.session?.status === "connecting";
813 error: event.kind === "error" && event.message ? event.message : t.error,904 return {
814 };905 ...t,
815 })(),906 codexThreadId: shouldRebindIdentity
816 session: t.session ? evolveSession(t.session, event) : t.session,907 ? (eventThreadId ?? t.codexThreadId)
817 messages: applyEventToMessages(t.messages, event, activeAssistantItemRef),908 : (t.codexThreadId ?? eventThreadId ?? null),
818 events: [event, ...t.events],909 error: event.kind === "error" && event.message ? event.message : t.error,
819 ...updateTurnFields(t, event),910 session: t.session ? evolveSession(t.session, event) : t.session,
820 ...(event.method === "turn/completed" && t.id === state.activeThreadId911 messages: applyEventToMessages(t.messages, event, activeAssistantItemRef),
821 ? { lastVisitedAt: event.createdAt }912 events: nextEvents,
822 : {}),913 turnDiffSummaries,
823 })),914 ...updateTurnFields(t, event),
915 ...(event.method === "turn/completed" && t.id === state.activeThreadId
916 ? { lastVisitedAt: event.createdAt }
917 : {}),
918 };
919 }),
824 };920 };
825 }921 }
826922
...@@ -893,6 +989,7 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -893,6 +989,7 @@ export function reducer(state: AppState, action: Action): AppState {
893 ...state,989 ...state,
894 threads: updateThread(state.threads, action.threadId, (t) => {990 threads: updateThread(state.threads, action.threadId, (t) => {
895 const nextMessageCount = Math.max(0, Math.floor(action.messageCount));991 const nextMessageCount = Math.max(0, Math.floor(action.messageCount));
992 const nextTurnCount = Math.max(0, Math.floor(action.turnCount));
896 const now = new Date().toISOString();993 const now = new Date().toISOString();
897 return {994 return {
898 ...t,995 ...t,
...@@ -910,6 +1007,11 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -910,6 +1007,11 @@ export function reducer(state: AppState, action: Action): AppState {
910 : t.session,1007 : t.session,
911 messages: t.messages.slice(0, nextMessageCount),1008 messages: t.messages.slice(0, nextMessageCount),
912 events: [],1009 events: [],
1010 turnDiffSummaries: t.turnDiffSummaries.filter(
1011 (summary) =>
1012 typeof summary.checkpointTurnCount === "number" &&
1013 summary.checkpointTurnCount <= nextTurnCount,
1014 ),
913 error: null,1015 error: null,
914 latestTurnId: undefined,1016 latestTurnId: undefined,
915 latestTurnStartedAt: undefined,1017 latestTurnStartedAt: undefined,
...@@ -920,6 +1022,83 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -920,6 +1022,83 @@ export function reducer(state: AppState, action: Action): AppState {
920 }),1022 }),
921 };1023 };
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
923 case "SET_THREAD_BRANCH": {1102 case "SET_THREAD_BRANCH": {
924 return {1103 return {
925 ...state,1104 ...state,
apps/web/src/types.ts+20
...@@ -37,6 +37,25 @@ export interface ChatMessage {...@@ -37,6 +37,25 @@ export interface ChatMessage {
37 streaming: boolean;37 streaming: boolean;
38}38}
3939
40export interface TurnDiffFileChange {
41 path: string;
42 kind?: string | undefined;
43 diff?: string | undefined;
44 additions?: number | undefined;
45 deletions?: number | undefined;
46}
47
48export 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
40export interface Project {59export interface Project {
41 id: string;60 id: string;
42 name: string;61 name: string;
...@@ -71,4 +90,5 @@ export interface Thread {...@@ -71,4 +90,5 @@ export interface Thread {
71 lastVisitedAt?: string | undefined;90 lastVisitedAt?: string | undefined;
72 branch: string | null;91 branch: string | null;
73 worktreePath: string | null;92 worktreePath: string | null;
93 turnDiffSummaries: TurnDiffSummary[];
74}94}
apps/web/src/worktreeCleanup.test.ts+1
...@@ -25,6 +25,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {...@@ -25,6 +25,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
25 session: null,25 session: null,
26 messages: [],26 messages: [],
27 events: [],27 events: [],
28 turnDiffSummaries: [],
28 error: null,29 error: null,
29 createdAt: "2026-02-13T00:00:00.000Z",30 createdAt: "2026-02-13T00:00:00.000Z",
30 branch: null,31 branch: null,
apps/web/src/wsNativeApi.ts+1
...@@ -92,6 +92,7 @@ export function createWsNativeApi(): NativeApi {...@@ -92,6 +92,7 @@ export function createWsNativeApi(): NativeApi {
92 stopSession: (input) => transport.request(WS_METHODS.providersStopSession, input),92 stopSession: (input) => transport.request(WS_METHODS.providersStopSession, input),
93 listSessions: () => transport.request(WS_METHODS.providersListSessions),93 listSessions: () => transport.request(WS_METHODS.providersListSessions),
94 listCheckpoints: (input) => transport.request(WS_METHODS.providersListCheckpoints, input),94 listCheckpoints: (input) => transport.request(WS_METHODS.providersListCheckpoints, input),
95 getCheckpointDiff: (input) => transport.request(WS_METHODS.providersGetCheckpointDiff, input),
95 revertToCheckpoint: (input) =>96 revertToCheckpoint: (input) =>
96 transport.request(WS_METHODS.providersRevertToCheckpoint, input),97 transport.request(WS_METHODS.providersRevertToCheckpoint, input),
97 onEvent: (callback) =>98 onEvent: (callback) =>
apps/web/src/wsTransport.ts+3
...@@ -37,6 +37,9 @@ export class WsTransport {...@@ -37,6 +37,9 @@ export class WsTransport {
37 }37 }
3838
39 async request<T = unknown>(method: string, params?: unknown): Promise<T> {39 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 }
40 const id = String(this.nextId++);43 const id = String(this.nextId++);
41 const message: WsRequest = { id, method, ...(params !== undefined ? { params } : {}) };44 const message: WsRequest = { id, method, ...(params !== undefined ? { params } : {}) };
4245
packages/contracts/package.json+1-1
...@@ -12,7 +12,7 @@...@@ -12,7 +12,7 @@
12 "exports": {12 "exports": {
13 ".": {13 ".": {
14 "types": "./src/index.ts",14 "types": "./src/index.ts",
15 "import": "./dist/index.mjs",15 "import": "./src/index.ts",
16 "require": "./dist/index.cjs"16 "require": "./dist/index.cjs"
17 }17 }
18 },18 },
packages/contracts/src/ipc.ts+5
...@@ -17,6 +17,8 @@ import type {...@@ -17,6 +17,8 @@ import type {
17} from "./git";17} from "./git";
18import type {18import type {
19 ProviderEvent,19 ProviderEvent,
20 ProviderGetCheckpointDiffInput,
21 ProviderGetCheckpointDiffResult,
20 ProviderInterruptTurnInput,22 ProviderInterruptTurnInput,
21 ProviderListCheckpointsInput,23 ProviderListCheckpointsInput,
22 ProviderListCheckpointsResult,24 ProviderListCheckpointsResult,
...@@ -94,6 +96,9 @@ export interface NativeApi {...@@ -94,6 +96,9 @@ export interface NativeApi {
94 stopSession: (input: ProviderStopSessionInput) => Promise<void>;96 stopSession: (input: ProviderStopSessionInput) => Promise<void>;
95 listSessions: () => Promise<ProviderSession[]>;97 listSessions: () => Promise<ProviderSession[]>;
96 listCheckpoints: (input: ProviderListCheckpointsInput) => Promise<ProviderListCheckpointsResult>;98 listCheckpoints: (input: ProviderListCheckpointsInput) => Promise<ProviderListCheckpointsResult>;
99 getCheckpointDiff: (
100 input: ProviderGetCheckpointDiffInput,
101 ) => Promise<ProviderGetCheckpointDiffResult>;
97 revertToCheckpoint: (102 revertToCheckpoint: (
98 input: ProviderRevertToCheckpointInput,103 input: ProviderRevertToCheckpointInput,
99 ) => Promise<ProviderRevertToCheckpointResult>;104 ) => Promise<ProviderRevertToCheckpointResult>;
packages/contracts/src/provider.test.ts+29
...@@ -4,6 +4,8 @@ import {...@@ -4,6 +4,8 @@ import {
4 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,4 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
5 providerCheckpointSchema,5 providerCheckpointSchema,
6 providerEventSchema,6 providerEventSchema,
7 providerGetCheckpointDiffInputSchema,
8 providerGetCheckpointDiffResultSchema,
7 providerListCheckpointsInputSchema,9 providerListCheckpointsInputSchema,
8 providerListCheckpointsResultSchema,10 providerListCheckpointsResultSchema,
9 providerRevertToCheckpointInputSchema,11 providerRevertToCheckpointInputSchema,
...@@ -229,4 +231,31 @@ describe("provider checkpoint schemas", () => {...@@ -229,4 +231,31 @@ describe("provider checkpoint schemas", () => {
229 }),231 }),
230 ).toThrow();232 ).toThrow();
231 });233 });
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 });
232});261});
packages/contracts/src/provider.ts+25
...@@ -142,6 +142,29 @@ export const providerRevertToCheckpointResultSchema = z.object({...@@ -142,6 +142,29 @@ export const providerRevertToCheckpointResultSchema = z.object({
142 checkpoints: z.array(providerCheckpointSchema),142 checkpoints: z.array(providerCheckpointSchema),
143});143});
144144
145export 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
161export 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
145export const providerRespondToRequestInputSchema = z.object({168export const providerRespondToRequestInputSchema = z.object({
146 sessionId: z.string().min(1),169 sessionId: z.string().min(1),
147 requestId: z.string().min(1),170 requestId: z.string().min(1),
...@@ -189,6 +212,8 @@ export type ProviderRevertToCheckpointInput = z.input<typeof providerRevertToChe...@@ -189,6 +212,8 @@ export type ProviderRevertToCheckpointInput = z.input<typeof providerRevertToChe
189export type ProviderRevertToCheckpointResult = z.infer<212export type ProviderRevertToCheckpointResult = z.infer<
190 typeof providerRevertToCheckpointResultSchema213 typeof providerRevertToCheckpointResultSchema
191>;214>;
215export type ProviderGetCheckpointDiffInput = z.input<typeof providerGetCheckpointDiffInputSchema>;
216export type ProviderGetCheckpointDiffResult = z.infer<typeof providerGetCheckpointDiffResultSchema>;
192export type ProviderRespondToRequestInput = z.input<typeof providerRespondToRequestInputSchema>;217export type ProviderRespondToRequestInput = z.input<typeof providerRespondToRequestInputSchema>;
193export type ProviderEventKind = z.infer<typeof providerEventKindSchema>;218export type ProviderEventKind = z.infer<typeof providerEventKindSchema>;
194export type ProviderEvent = z.infer<typeof providerEventSchema>;219export type ProviderEvent = z.infer<typeof providerEventSchema>;
packages/contracts/src/ws.ts+1
...@@ -11,6 +11,7 @@ export const WS_METHODS = {...@@ -11,6 +11,7 @@ export const WS_METHODS = {
11 providersStopSession: "providers.stopSession",11 providersStopSession: "providers.stopSession",
12 providersListSessions: "providers.listSessions",12 providersListSessions: "providers.listSessions",
13 providersListCheckpoints: "providers.listCheckpoints",13 providersListCheckpoints: "providers.listCheckpoints",
14 providersGetCheckpointDiff: "providers.getCheckpointDiff",
14 providersRevertToCheckpoint: "providers.revertToCheckpoint",15 providersRevertToCheckpoint: "providers.revertToCheckpoint",
1516
16 // Project registry methods17 // Project registry methods