- 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
Unified · Split
| @@ -148,19 +148,20 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 147 unmodified lines | ||
| 148 | 148 | async startSession(input: ProviderSessionStartInput): Promise<ProviderSession> { |
| 149 | 149 | const sessionId = randomUUID(); |
| 150 | 150 | const now = new Date().toISOString(); |
| 151 | + const resolvedCwd = input.cwd ?? process.cwd(); | |
| 151 | 152 | |
| 152 | 153 | const session: ProviderSession = { |
| 153 | 154 | sessionId, |
| 154 | 155 | provider: "codex", |
| 155 | 156 | status: "connecting", |
| 156 | 157 | model: normalizeCodexModelSlug(input.model), |
| 157 | − cwd: input.cwd, | |
| 158 | + cwd: resolvedCwd, | |
| 158 | 159 | createdAt: now, |
| 159 | 160 | updatedAt: now, |
| 160 | 161 | }; |
| 161 | 162 | |
| 162 | 163 | const child = spawn("codex", ["app-server"], { |
| 163 | − cwd: input.cwd, | |
| 164 | + cwd: resolvedCwd, | |
| 164 | 165 | env: process.env, |
| 165 | 166 | stdio: ["pipe", "pipe", "pipe"], |
| 166 | 167 | }); |
| @@ -198,7 +199,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 31 unmodified lines | ||
| 198 | 199 | const normalizedModel = normalizeCodexModelSlug(input.model); |
| 199 | 200 | const sessionOverrides = { |
| 200 | 201 | model: normalizedModel ?? null, |
| 201 | − cwd: input.cwd ?? null, | |
| 202 | + cwd: resolvedCwd, | |
| 202 | 203 | approvalPolicy: input.approvalPolicy, |
| 203 | 204 | sandbox: input.sandboxMode, |
| 204 | 205 | }; |
| @@ -93,6 +93,32 @@ describe("FilesystemCheckpointStore", () => { · 92 unmodified lines | ||
| 93 | 93 | } |
| 94 | 94 | }); |
| 95 | 95 | |
| 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 | 122 | it("prunes refs newer than the retained turn", async () => { |
| 97 | 123 | const cwd = await createRepo(); |
| 98 | 124 | const store = new FilesystemCheckpointStore(); |
| @@ -119,6 +145,55 @@ describe("FilesystemCheckpointStore", () => { · 20 unmodified lines | ||
| 119 | 145 | } |
| 120 | 146 | }); |
| 121 | 147 | |
| 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 | 197 | it("detects non-repo folders", async () => { |
| 123 | 198 | const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoints-nonrepo-")); |
| 124 | 199 | const store = new FilesystemCheckpointStore(); |
| @@ -91,10 +91,30 @@ export class FilesystemCheckpointStore { · 90 unmodified lines | ||
| 91 | 91 | return commit !== null; |
| 92 | 92 | } |
| 93 | 93 | |
| 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 | 111 | async restoreCheckpoint(input: { cwd: string; threadId: string; turnCount: number }): Promise<boolean> { |
| 95 | 112 | const { cwd, threadId, turnCount } = input; |
| 96 | 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 | 118 | if (!commitOid) { |
| 99 | 119 | return false; |
| 100 | 120 | } |
| @@ -105,6 +125,64 @@ export class FilesystemCheckpointStore { · 4 unmodified lines | ||
| 105 | 125 | return true; |
| 106 | 126 | } |
| 107 | 127 | |
| 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 | 186 | async pruneAfterTurn(input: { cwd: string; threadId: string; maxTurnCount: number }): Promise<void> { |
| 109 | 187 | const { cwd, threadId, maxTurnCount } = input; |
| 110 | 188 | if (!Number.isInteger(maxTurnCount) || maxTurnCount < 0) { |
| @@ -76,6 +76,14 @@ describe("ProviderManager", () => { · 75 unmodified lines | ||
| 76 | 76 | }), |
| 77 | 77 | ).rejects.toThrow("Unknown provider session: missing-session"); |
| 78 | 78 | |
| 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 | 87 | manager.dispose(); |
| 80 | 88 | }); |
| 81 | 89 | |
| @@ -155,12 +163,39 @@ describe("ProviderManager", () => { · 73 unmodified lines | ||
| 155 | 163 | threadId: string; |
| 156 | 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 | 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 | + }; | |
| 161 | 187 | |
| 162 | 188 | const rollbackCalls: Array<{ sessionId: string; numTurns: number }> = []; |
| 163 | 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 | 199 | codex.readThread = async () => ({ |
| 165 | 200 | threadId: "thr_1", |
| 166 | 201 | turns: [ |
| @@ -176,6 +211,8 @@ describe("ProviderManager", () => { · 9 unmodified lines | ||
| 176 | 211 | turns: [{ id: "turn_1", items: [] }], |
| 177 | 212 | }; |
| 178 | 213 | }; |
| 214 | + internals.filesystemCheckpointStore.isGitRepository = async () => false; | |
| 215 | + internals.filesystemCheckpointStore.ensureRootCheckpoint = async () => true; | |
| 179 | 216 | |
| 180 | 217 | const result = await manager.revertToCheckpoint({ |
| 181 | 218 | sessionId: "sess_1", |
| @@ -192,6 +229,239 @@ describe("ProviderManager", () => { · 10 unmodified lines | ||
| 192 | 229 | manager.dispose(); |
| 193 | 230 | }); |
| 194 | 231 | |
| 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 | 465 | it("restores filesystem checkpoint when reverting and checkpointing is enabled", async () => { |
| 196 | 466 | const manager = new ProviderManager(); |
| 197 | 467 | const internals = manager as unknown as { |
| @@ -263,6 +533,73 @@ describe("ProviderManager", () => { · 65 unmodified lines | ||
| 263 | 533 | manager.dispose(); |
| 264 | 534 | }); |
| 265 | 535 | |
| 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 | 603 | it("fails before rollback when filesystem checkpoint is missing", async () => { |
| 267 | 604 | const manager = new ProviderManager(); |
| 268 | 605 | const internals = manager as unknown as { |
| @@ -6,6 +6,8 @@ import { randomUUID } from "node:crypto"; · 5 unmodified lines | ||
| 6 | 6 | import { |
| 7 | 7 | type ProviderCheckpoint, |
| 8 | 8 | type ProviderEvent, |
| 9 | + type ProviderGetCheckpointDiffInput, | |
| 10 | + type ProviderGetCheckpointDiffResult, | |
| 9 | 11 | type ProviderInterruptTurnInput, |
| 10 | 12 | type ProviderListCheckpointsInput, |
| 11 | 13 | type ProviderListCheckpointsResult, |
| @@ -18,6 +20,7 @@ import { · 6 unmodified lines | ||
| 18 | 20 | type ProviderStopSessionInput, |
| 19 | 21 | type ProviderTurnStartResult, |
| 20 | 22 | providerInterruptTurnInputSchema, |
| 23 | + providerGetCheckpointDiffInputSchema, | |
| 21 | 24 | providerListCheckpointsInputSchema, |
| 22 | 25 | providerRevertToCheckpointInputSchema, |
| 23 | 26 | providerRespondToRequestInputSchema, |
| @@ -198,7 +201,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 174 unmodified lines | ||
| 198 | 201 | this.sessionThreadIds.set(session.sessionId, session.threadId); |
| 199 | 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 | 205 | const message = |
| 203 | 206 | error instanceof Error ? error.message : "Failed to initialize filesystem checkpoints."; |
| 204 | 207 | this.emitFilesystemCheckpointError(session.sessionId, message, session.threadId); |
| @@ -259,6 +262,43 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 54 unmodified lines | ||
| 259 | 262 | }; |
| 260 | 263 | } |
| 261 | 264 | |
| 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 | 302 | async revertToCheckpoint( |
| 263 | 303 | raw: ProviderRevertToCheckpointInput, |
| 264 | 304 | ): Promise<ProviderRevertToCheckpointResult> { |
| @@ -267,6 +307,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 2 unmodified lines | ||
| 267 | 307 | throw new Error(`Unknown provider session: ${input.sessionId}`); |
| 268 | 308 | } |
| 269 | 309 | |
| 310 | + const checkpointCwd = await this.getOrInitializeFilesystemCheckpointCwd(input.sessionId); | |
| 270 | 311 | return this.withFilesystemLock(input.sessionId, async () => { |
| 271 | 312 | const beforeSnapshot = await this.codex.readThread(input.sessionId); |
| 272 | 313 | const currentTurnCount = beforeSnapshot.turns.length; |
| @@ -276,8 +317,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 3 unmodified lines | ||
| 276 | 317 | ); |
| 277 | 318 | } |
| 278 | 319 | |
| 279 | − const checkpointCwd = this.sessionCheckpointCwds.get(input.sessionId); | |
| 280 | − if (checkpointCwd) { | |
| 320 | + if (checkpointCwd && input.turnCount > 0) { | |
| 281 | 321 | const hasCheckpoint = await this.filesystemCheckpointStore.hasCheckpoint({ |
| 282 | 322 | cwd: checkpointCwd, |
| 283 | 323 | threadId: beforeSnapshot.threadId, |
| @@ -352,8 +392,11 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 68 unmodified lines | ||
| 352 | 392 | this.filesystemLocks.clear(); |
| 353 | 393 | } |
| 354 | 394 | |
| 355 | − private async initializeFilesystemCheckpointing(session: ProviderSession): Promise<void> { | |
| 356 | − const cwd = session.cwd ?? process.cwd(); | |
| 395 | + private async initializeFilesystemCheckpointing( | |
| 396 | + session: ProviderSession, | |
| 397 | + preferredCwd?: string, | |
| 398 | + ): Promise<void> { | |
| 399 | + const cwd = preferredCwd ?? session.cwd ?? process.cwd(); | |
| 357 | 400 | |
| 358 | 401 | await this.withFilesystemLock(session.sessionId, async () => { |
| 359 | 402 | const supportsGit = await this.filesystemCheckpointStore.isGitRepository(cwd); |
| @@ -363,6 +406,10 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 3 unmodified lines | ||
| 363 | 406 | } |
| 364 | 407 | |
| 365 | 408 | const snapshot = await this.codex.readThread(session.sessionId); |
| 409 | + await this.filesystemCheckpointStore.ensureRootCheckpoint({ | |
| 410 | + cwd, | |
| 411 | + threadId: snapshot.threadId, | |
| 412 | + }); | |
| 366 | 413 | await this.filesystemCheckpointStore.captureCheckpoint({ |
| 367 | 414 | cwd, |
| 368 | 415 | threadId: snapshot.threadId, |
| @@ -372,6 +419,54 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 3 unmodified lines | ||
| 372 | 419 | }); |
| 373 | 420 | } |
| 374 | 421 | |
| 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 | 470 | private maybeCaptureFilesystemCheckpoint(event: ProviderEvent): void { |
| 376 | 471 | if (event.kind !== "notification" || event.method !== "turn/completed") { |
| 377 | 472 | return; |
| @@ -379,6 +474,11 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 1 unmodified line | ||
| 379 | 474 | |
| 380 | 475 | const checkpointCwd = this.sessionCheckpointCwds.get(event.sessionId); |
| 381 | 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 | 482 | return; |
| 383 | 483 | } |
| 384 | 484 | |
| @@ -668,6 +668,14 @@ describe("WebSocket Server", () => { · 667 unmodified lines | ||
| 668 | 668 | }); |
| 669 | 669 | expect(revertResponse.result).toBeUndefined(); |
| 670 | 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 | }); |
| 672 | 680 | |
| 673 | 681 | it("routes terminal RPC methods and broadcasts terminal events", async () => { |
| @@ -313,6 +313,9 @@ export function createServer(options: ServerOptions) { · 312 unmodified lines | ||
| 313 | 313 | case WS_METHODS.providersListCheckpoints: |
| 314 | 314 | return providerManager.listCheckpoints(request.params as never); |
| 315 | 315 | |
| 316 | + case WS_METHODS.providersGetCheckpointDiff: | |
| 317 | + return providerManager.getCheckpointDiff(request.params as never); | |
| 318 | + | |
| 316 | 319 | case WS_METHODS.providersRevertToCheckpoint: |
| 317 | 320 | return providerManager.revertToCheckpoint(request.params as never); |
| 318 | 321 | |
| @@ -9,10 +9,13 @@ import { StoreProvider, useStore } from "./store"; · 8 unmodified lines | ||
| 9 | 9 | import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types"; |
| 10 | 10 | import { onServerWelcome } from "./wsNativeApi"; |
| 11 | 11 | import { useNativeApi } from "./hooks/useNativeApi"; |
| 12 | +import { useMediaQuery } from "./hooks/useMediaQuery"; | |
| 12 | 13 | import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast"; |
| 14 | +import { Sheet, SheetPopup } from "./components/ui/sheet"; | |
| 13 | 15 | import { invalidateGitQueries } from "./lib/gitReactQuery"; |
| 14 | 16 | |
| 15 | 17 | const DiffPanel = lazy(() => import("./components/DiffPanel")); |
| 18 | +const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; | |
| 16 | 19 | |
| 17 | 20 | function EventRouter() { |
| 18 | 21 | const api = useNativeApi(); |
| @@ -112,6 +115,7 @@ function AutoProjectBootstrap() { · 93 unmodified lines | ||
| 112 | 115 | session: null, |
| 113 | 116 | messages: [], |
| 114 | 117 | events: [], |
| 118 | + turnDiffSummaries: [], | |
| 115 | 119 | error: null, |
| 116 | 120 | createdAt: new Date().toISOString(), |
| 117 | 121 | branch: null, |
| @@ -177,7 +181,8 @@ function DesktopProjectBootstrap() { · 59 unmodified lines | ||
| 177 | 181 | |
| 178 | 182 | function Layout() { |
| 179 | 183 | const api = useNativeApi(); |
| 180 | − const { state } = useStore(); | |
| 184 | + const { state, dispatch } = useStore(); | |
| 185 | + const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); | |
| 181 | 186 | |
| 182 | 187 | if (!api) { |
| 183 | 188 | return ( |
| @@ -196,7 +201,7 @@ function Layout() { · 12 unmodified lines | ||
| 196 | 201 | <DesktopProjectBootstrap /> |
| 197 | 202 | <Sidebar /> |
| 198 | 203 | <ChatView /> |
| 199 | − {state.diffOpen && ( | |
| 204 | + {state.diffOpen && !shouldUseDiffSheet && ( | |
| 200 | 205 | <Suspense |
| 201 | 206 | fallback={ |
| 202 | 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() { · 1 unmodified line | ||
| 204 | 209 | </aside> |
| 205 | 210 | } |
| 206 | 211 | > |
| 207 | − <DiffPanel /> | |
| 212 | + <DiffPanel mode="inline" /> | |
| 208 | 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 | 241 | </div> |
| 211 | 242 | ); |
| 212 | 243 | } |
| @@ -8,7 +8,6 @@ import { · 7 unmodified lines | ||
| 8 | 8 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 9 | 9 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 10 | 10 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 11 | − type ProviderCheckpoint, | |
| 12 | 11 | type ResolvedKeybindingsConfig, |
| 13 | 12 | type ProviderApprovalDecision, |
| 14 | 13 | type ProviderSendTurnAttachmentInput, |
| @@ -50,10 +49,12 @@ import { · 35 unmodified lines | ||
| 50 | 49 | resolveModelSlug, |
| 51 | 50 | } from "../model-logic"; |
| 52 | 51 | import { |
| 52 | + countDiffStat, | |
| 53 | 53 | derivePendingApprovals, |
| 54 | 54 | derivePhase, |
| 55 | 55 | deriveTurnDiffSummaries, |
| 56 | 56 | deriveTimelineEntries, |
| 57 | + inferCheckpointTurnCountByTurnId, | |
| 57 | 58 | type TurnDiffSummary, |
| 58 | 59 | type PendingApproval, |
| 59 | 60 | deriveWorkLogEntries, |
| @@ -87,6 +88,7 @@ import { · 27 unmodified lines | ||
| 87 | 88 | CircleAlertIcon, |
| 88 | 89 | FileIcon, |
| 89 | 90 | FolderIcon, |
| 91 | + DiffIcon, | |
| 90 | 92 | FolderClosedIcon, |
| 91 | 93 | InfoIcon, |
| 92 | 94 | LockIcon, |
| @@ -348,9 +350,7 @@ export default function ChatView() { · 255 unmodified lines | ||
| 348 | 350 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); |
| 349 | 351 | const [isSending, setIsSending] = useState(false); |
| 350 | 352 | const [isConnecting, setIsConnecting] = useState(false); |
| 351 | − const [isLoadingCheckpoints, setIsLoadingCheckpoints] = useState(false); | |
| 352 | 353 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); |
| 353 | − const [checkpoints, setCheckpoints] = useState<ProviderCheckpoint[]>([]); | |
| 354 | 354 | const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); |
| 355 | 355 | const [envMode, setEnvMode] = useState<"local" | "worktree">("local"); |
| 356 | 356 | const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); |
| @@ -371,10 +371,13 @@ export default function ChatView() { · 14 unmodified lines | ||
| 371 | 371 | const composerImagesRef = useRef<ComposerImageAttachment[]>([]); |
| 372 | 372 | const dragDepthRef = useRef(0); |
| 373 | 373 | const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); |
| 374 | + const checkpointDiffRequestsRef = useRef(new Set<string>()); | |
| 375 | + const checkpointHydrationSessionRequestRef = useRef(new Set<string>()); | |
| 374 | 376 | |
| 375 | 377 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); |
| 376 | 378 | const activeThreadId = activeThread?.id ?? null; |
| 377 | 379 | const activeSessionId = activeThread?.session?.sessionId; |
| 380 | + const activeThreadRuntimeId = activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; | |
| 378 | 381 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); |
| 379 | 382 | const selectedModel = resolveModelSlug( |
| 380 | 383 | activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, |
| @@ -409,8 +412,11 @@ export default function ChatView() { · 28 unmodified lines | ||
| 409 | 412 | [activeThread?.messages, workLogEntries], |
| 410 | 413 | ); |
| 411 | 414 | const turnDiffSummaries = useMemo( |
| 412 | − () => deriveTurnDiffSummaries(activeThread?.events ?? []), | |
| 413 | − [activeThread?.events], | |
| 415 | + () => | |
| 416 | + activeThread?.turnDiffSummaries.length | |
| 417 | + ? activeThread.turnDiffSummaries | |
| 418 | + : deriveTurnDiffSummaries(activeThread?.events ?? []), | |
| 419 | + [activeThread?.events, activeThread?.turnDiffSummaries], | |
| 414 | 420 | ); |
| 415 | 421 | const turnDiffSummaryByAssistantMessageId = useMemo(() => { |
| 416 | 422 | const byMessageId = new Map<string, TurnDiffSummary>(); |
| @@ -420,6 +426,134 @@ export default function ChatView() { · 3 unmodified lines | ||
| 420 | 426 | } |
| 421 | 427 | return byMessageId; |
| 422 | 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 | 557 | const completionSummary = useMemo(() => { |
| 424 | 558 | if (!activeThread?.latestTurnStartedAt) return null; |
| 425 | 559 | if (!activeThread.latestTurnCompletedAt) return null; |
| @@ -475,16 +609,9 @@ export default function ChatView() { · 49 unmodified lines | ||
| 475 | 609 | completionSummary, |
| 476 | 610 | timelineEntries, |
| 477 | 611 | ]); |
| 478 | − const runtimeSessionConfig = | |
| 479 | − state.runtimeMode === "full-access" | |
| 480 | − ? ({ | |
| 481 | − approvalPolicy: "never", | |
| 482 | − sandboxMode: "danger-full-access", | |
| 483 | − } as const) | |
| 484 | − : ({ | |
| 485 | − approvalPolicy: "on-request", | |
| 486 | − sandboxMode: "workspace-write", | |
| 487 | − } as const); | |
| 612 | + const runtimeApprovalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request"; | |
| 613 | + const runtimeSandboxMode = | |
| 614 | + state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write"; | |
| 488 | 615 | const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; |
| 489 | 616 | const composerTrigger = useMemo( |
| 490 | 617 | () => detectComposerTrigger(prompt, composerCursor), |
| @@ -593,12 +720,6 @@ export default function ChatView() { · 102 unmodified lines | ||
| 593 | 720 | ); |
| 594 | 721 | const hasReachedTerminalLimit = |
| 595 | 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; | |
| 602 | 723 | |
| 603 | 724 | const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => { |
| 604 | 725 | for (const image of images) { |
| @@ -988,8 +1109,6 @@ export default function ChatView() { · 383 unmodified lines | ||
| 988 | 1109 | }, [composerMenuItems, composerMenuOpen]); |
| 989 | 1110 | |
| 990 | 1111 | useEffect(() => { |
| 991 | − setCheckpoints([]); | |
| 992 | − setIsLoadingCheckpoints(false); | |
| 993 | 1112 | setIsRevertingCheckpoint(false); |
| 994 | 1113 | }, [activeThread?.id]); |
| 995 | 1114 | |
| @@ -1172,14 +1291,17 @@ export default function ChatView() { · 176 unmodified lines | ||
| 1172 | 1291 | toggleTerminalVisibility, |
| 1173 | 1292 | ]); |
| 1174 | 1293 | |
| 1175 | − const setThreadError = (threadId: string | null, error: string | null) => { | |
| 1176 | − if (!threadId) return; | |
| 1177 | − dispatch({ | |
| 1178 | − type: "SET_ERROR", | |
| 1179 | − threadId, | |
| 1180 | − error, | |
| 1181 | − }); | |
| 1182 | − }; | |
| 1294 | + const setThreadError = useCallback( | |
| 1295 | + (threadId: string | null, error: string | null) => { | |
| 1296 | + if (!threadId) return; | |
| 1297 | + dispatch({ | |
| 1298 | + type: "SET_ERROR", | |
| 1299 | + threadId, | |
| 1300 | + error, | |
| 1301 | + }); | |
| 1302 | + }, | |
| 1303 | + [dispatch], | |
| 1304 | + ); | |
| 1183 | 1305 | |
| 1184 | 1306 | const addComposerImages = (files: File[]) => { |
| 1185 | 1307 | if (!activeThreadId || files.length === 0) return; |
| @@ -1284,134 +1406,169 @@ export default function ChatView() { · 98 unmodified lines | ||
| 1284 | 1406 | focusComposer(); |
| 1285 | 1407 | }; |
| 1286 | 1408 | |
| 1287 | − const ensureSession = async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => { | |
| 1288 | − if (!api || !activeThread || !activeProject) return null; | |
| 1289 | − if (activeThread.session && activeThread.session.status !== "closed") { | |
| 1290 | − const sessionThreadId = activeThread.session.threadId ?? null; | |
| 1291 | − const continuityState: SessionContinuityState = | |
| 1292 | − activeThread.codexThreadId === null | |
| 1293 | − ? "new" | |
| 1294 | − : sessionThreadId === activeThread.codexThreadId | |
| 1295 | − ? "resumed" | |
| 1296 | − : "fallback_new"; | |
| 1297 | − return { | |
| 1298 | − sessionId: activeThread.session.sessionId, | |
| 1299 | − resolvedThreadId: sessionThreadId, | |
| 1300 | − continuityState, | |
| 1301 | − } satisfies EnsuredSessionInfo; | |
| 1302 | − } | |
| 1303 | − | |
| 1304 | − const priorCodexThreadId = activeThread.codexThreadId; | |
| 1305 | − setIsConnecting(true); | |
| 1306 | − try { | |
| 1307 | − const session = await api.providers.startSession({ | |
| 1308 | − provider: "codex", | |
| 1309 | − cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd, | |
| 1310 | − model: selectedModel || undefined, | |
| 1311 | − resumeThreadId: priorCodexThreadId ?? undefined, | |
| 1312 | − approvalPolicy: runtimeSessionConfig.approvalPolicy, | |
| 1313 | − sandboxMode: runtimeSessionConfig.sandboxMode, | |
| 1314 | − }); | |
| 1315 | − dispatch({ | |
| 1316 | − type: "UPDATE_SESSION", | |
| 1317 | − threadId: activeThread.id, | |
| 1318 | − session, | |
| 1319 | − }); | |
| 1320 | − const resolvedThreadId = session.threadId ?? null; | |
| 1321 | − const continuityState: SessionContinuityState = | |
| 1322 | − priorCodexThreadId === null | |
| 1323 | − ? "new" | |
| 1324 | − : resolvedThreadId === priorCodexThreadId | |
| 1325 | − ? "resumed" | |
| 1326 | − : "fallback_new"; | |
| 1327 | − return { | |
| 1328 | − sessionId: session.sessionId, | |
| 1329 | − resolvedThreadId, | |
| 1330 | − continuityState, | |
| 1331 | − }; | |
| 1332 | − } catch (err) { | |
| 1333 | − dispatch({ | |
| 1334 | − type: "SET_ERROR", | |
| 1335 | − threadId: activeThread.id, | |
| 1336 | − error: err instanceof Error ? err.message : "Failed to connect.", | |
| 1337 | − }); | |
| 1338 | − return null; | |
| 1339 | − } finally { | |
| 1340 | − setIsConnecting(false); | |
| 1341 | − } | |
| 1342 | − }; | |
| 1343 | − | |
| 1344 | − const loadCheckpoints = async (): Promise<ProviderCheckpoint[] | null> => { | |
| 1345 | − if (!api || !activeThread) return null; | |
| 1346 | − if (phase === "running" || isSending || isConnecting || isRevertingCheckpoint) { | |
| 1347 | − return null; | |
| 1348 | − } | |
| 1349 | − const hasThreadIdentity = Boolean(activeThread.codexThreadId ?? activeThread.session?.threadId); | |
| 1350 | − if (!hasThreadIdentity) { | |
| 1351 | − setCheckpoints([]); | |
| 1352 | − return []; | |
| 1353 | − } | |
| 1409 | + const ensureSession = useCallback( | |
| 1410 | + async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => { | |
| 1411 | + if (!api || !activeThread || !activeProject) return null; | |
| 1412 | + if (activeThread.session && activeThread.session.status !== "closed") { | |
| 1413 | + const sessionThreadId = activeThread.session.threadId ?? null; | |
| 1414 | + const continuityState: SessionContinuityState = | |
| 1415 | + activeThread.codexThreadId === null | |
| 1416 | + ? "new" | |
| 1417 | + : sessionThreadId === activeThread.codexThreadId | |
| 1418 | + ? "resumed" | |
| 1419 | + : "fallback_new"; | |
| 1420 | + return { | |
| 1421 | + sessionId: activeThread.session.sessionId, | |
| 1422 | + resolvedThreadId: sessionThreadId, | |
| 1423 | + continuityState, | |
| 1424 | + } satisfies EnsuredSessionInfo; | |
| 1425 | + } | |
| 1354 | 1426 | |
| 1355 | − setIsLoadingCheckpoints(true); | |
| 1356 | − setThreadError(activeThread.id, null); | |
| 1357 | − try { | |
| 1358 | − const sessionInfo = await ensureSession(); | |
| 1359 | − if (!sessionInfo) { | |
| 1427 | + const priorCodexThreadId = activeThread.codexThreadId; | |
| 1428 | + setIsConnecting(true); | |
| 1429 | + try { | |
| 1430 | + const session = await api.providers.startSession({ | |
| 1431 | + provider: "codex", | |
| 1432 | + cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd, | |
| 1433 | + model: selectedModel || undefined, | |
| 1434 | + resumeThreadId: priorCodexThreadId ?? undefined, | |
| 1435 | + approvalPolicy: runtimeApprovalPolicy, | |
| 1436 | + sandboxMode: runtimeSandboxMode, | |
| 1437 | + }); | |
| 1438 | + dispatch({ | |
| 1439 | + type: "UPDATE_SESSION", | |
| 1440 | + threadId: activeThread.id, | |
| 1441 | + session, | |
| 1442 | + }); | |
| 1443 | + const resolvedThreadId = session.threadId ?? null; | |
| 1444 | + const continuityState: SessionContinuityState = | |
| 1445 | + priorCodexThreadId === null | |
| 1446 | + ? "new" | |
| 1447 | + : resolvedThreadId === priorCodexThreadId | |
| 1448 | + ? "resumed" | |
| 1449 | + : "fallback_new"; | |
| 1450 | + return { | |
| 1451 | + sessionId: session.sessionId, | |
| 1452 | + resolvedThreadId, | |
| 1453 | + continuityState, | |
| 1454 | + }; | |
| 1455 | + } catch (err) { | |
| 1456 | + dispatch({ | |
| 1457 | + type: "SET_ERROR", | |
| 1458 | + threadId: activeThread.id, | |
| 1459 | + error: err instanceof Error ? err.message : "Failed to connect.", | |
| 1460 | + }); | |
| 1360 | 1461 | return null; |
| 1462 | + } finally { | |
| 1463 | + setIsConnecting(false); | |
| 1361 | 1464 | } |
| 1362 | − const result = await api.providers.listCheckpoints({ | |
| 1363 | − sessionId: sessionInfo.sessionId, | |
| 1364 | − }); | |
| 1365 | − setCheckpoints(result.checkpoints); | |
| 1366 | − return result.checkpoints; | |
| 1367 | − } catch (err) { | |
| 1368 | − setThreadError( | |
| 1369 | − activeThread.id, | |
| 1370 | − err instanceof Error ? err.message : "Failed to load checkpoints.", | |
| 1371 | − ); | |
| 1372 | − return null; | |
| 1373 | − } finally { | |
| 1374 | − setIsLoadingCheckpoints(false); | |
| 1375 | − } | |
| 1376 | − }; | |
| 1465 | + }, | |
| 1466 | + [activeProject, activeThread, api, dispatch, runtimeApprovalPolicy, runtimeSandboxMode, selectedModel], | |
| 1467 | + ); | |
| 1377 | 1468 | |
| 1378 | − const onRevertToCheckpoint = async (checkpoint: ProviderCheckpoint) => { | |
| 1379 | − if (!api || !activeThread || checkpoint.isCurrent || isRevertingCheckpoint) { | |
| 1380 | − return; | |
| 1381 | − } | |
| 1382 | − if (phase === "running" || isSending || isConnecting) { | |
| 1383 | − setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); | |
| 1469 | + useEffect(() => { | |
| 1470 | + if (!api || !activeThreadId || activeSessionId) return; | |
| 1471 | + if (phase === "running" || isSending || isConnecting || isRevertingCheckpoint) return; | |
| 1472 | + const hasThreadIdentity = Boolean(activeThreadRuntimeId); | |
| 1473 | + if (!hasThreadIdentity) return; | |
| 1474 | + const selectedDiffTurnId = | |
| 1475 | + state.diffOpen && state.diffThreadId === activeThreadId ? state.diffTurnId : null; | |
| 1476 | + const selectedDiffTurn = | |
| 1477 | + turnDiffSummaries.find((summary) => summary.turnId === selectedDiffTurnId) ?? turnDiffSummaries[0]; | |
| 1478 | + const selectedTurnMissingPatchBody = Boolean( | |
| 1479 | + selectedDiffTurn && | |
| 1480 | + !selectedDiffTurn.unifiedDiff && | |
| 1481 | + selectedDiffTurn.files.every((file) => !file.diff), | |
| 1482 | + ); | |
| 1483 | + const hasPendingDiffHydration = | |
| 1484 | + turnDiffSummaries.some((summary) => !summary.checkpointDiffLoaded) || selectedTurnMissingPatchBody; | |
| 1485 | + if (!hasPendingDiffHydration) return; | |
| 1486 | + | |
| 1487 | + const requestKey = `${activeThreadId}:${activeThreadRuntimeId ?? "none"}`; | |
| 1488 | + if (checkpointHydrationSessionRequestRef.current.has(requestKey)) { | |
| 1384 | 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 | + ]); | |
| 1386 | 1512 | |
| 1387 | − setIsRevertingCheckpoint(true); | |
| 1388 | − setThreadError(activeThread.id, null); | |
| 1389 | − try { | |
| 1390 | − const sessionInfo = await ensureSession(); | |
| 1391 | − if (!sessionInfo) { | |
| 1513 | + const onRevertToTurnCount = useCallback( | |
| 1514 | + async (turnCount: number) => { | |
| 1515 | + if (!api || !activeThread || isRevertingCheckpoint) { | |
| 1516 | + return; | |
| 1517 | + } | |
| 1518 | + if (phase === "running" || isSending || isConnecting) { | |
| 1519 | + setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); | |
| 1392 | 1520 | return; |
| 1393 | 1521 | } |
| 1394 | − const result = await api.providers.revertToCheckpoint({ | |
| 1395 | − sessionId: sessionInfo.sessionId, | |
| 1396 | − turnCount: checkpoint.turnCount, | |
| 1397 | − }); | |
| 1398 | − dispatch({ | |
| 1399 | − type: "REVERT_TO_CHECKPOINT", | |
| 1400 | − threadId: activeThread.id, | |
| 1401 | − sessionId: sessionInfo.sessionId, | |
| 1402 | − threadRuntimeId: result.threadId, | |
| 1403 | − messageCount: result.messageCount, | |
| 1404 | − }); | |
| 1405 | − setCheckpoints(result.checkpoints); | |
| 1406 | − } catch (err) { | |
| 1407 | − setThreadError( | |
| 1408 | − activeThread.id, | |
| 1409 | − err instanceof Error ? err.message : "Failed to revert checkpoint.", | |
| 1410 | − ); | |
| 1411 | − } finally { | |
| 1412 | − setIsRevertingCheckpoint(false); | |
| 1413 | − } | |
| 1414 | − }; | |
| 1522 | + | |
| 1523 | + setIsRevertingCheckpoint(true); | |
| 1524 | + setThreadError(activeThread.id, null); | |
| 1525 | + try { | |
| 1526 | + const sessionInfo = await ensureSession(); | |
| 1527 | + if (!sessionInfo) { | |
| 1528 | + return; | |
| 1529 | + } | |
| 1530 | + const result = await api.providers.revertToCheckpoint({ | |
| 1531 | + sessionId: sessionInfo.sessionId, | |
| 1532 | + turnCount, | |
| 1533 | + }); | |
| 1534 | + dispatch({ | |
| 1535 | + type: "REVERT_TO_CHECKPOINT", | |
| 1536 | + threadId: activeThread.id, | |
| 1537 | + sessionId: sessionInfo.sessionId, | |
| 1538 | + threadRuntimeId: result.threadId, | |
| 1539 | + turnCount: result.turnCount, | |
| 1540 | + messageCount: result.messageCount, | |
| 1541 | + }); | |
| 1542 | + dispatch({ | |
| 1543 | + type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", | |
| 1544 | + threadId: activeThread.id, | |
| 1545 | + checkpointTurnCountByTurnId: Object.fromEntries( | |
| 1546 | + result.checkpoints | |
| 1547 | + .filter((entry) => entry.turnCount > 0) | |
| 1548 | + .map((entry) => [entry.id, entry.turnCount] as const), | |
| 1549 | + ), | |
| 1550 | + }); | |
| 1551 | + } catch (err) { | |
| 1552 | + setThreadError( | |
| 1553 | + activeThread.id, | |
| 1554 | + err instanceof Error ? err.message : "Failed to revert thread state.", | |
| 1555 | + ); | |
| 1556 | + } finally { | |
| 1557 | + setIsRevertingCheckpoint(false); | |
| 1558 | + } | |
| 1559 | + }, | |
| 1560 | + [ | |
| 1561 | + activeThread, | |
| 1562 | + api, | |
| 1563 | + dispatch, | |
| 1564 | + ensureSession, | |
| 1565 | + isConnecting, | |
| 1566 | + isRevertingCheckpoint, | |
| 1567 | + isSending, | |
| 1568 | + phase, | |
| 1569 | + setThreadError, | |
| 1570 | + ], | |
| 1571 | + ); | |
| 1415 | 1572 | |
| 1416 | 1573 | const onSend = async (e: FormEvent) => { |
| 1417 | 1574 | e.preventDefault(); |
| @@ -1704,8 +1861,7 @@ export default function ChatView() { · 286 unmodified lines | ||
| 1704 | 1861 | return; |
| 1705 | 1862 | } |
| 1706 | 1863 | |
| 1707 | − const latestSummary = turnDiffSummaries[0]; | |
| 1708 | − if (!activeThread || !latestSummary) { | |
| 1864 | + if (!activeThread) { | |
| 1709 | 1865 | dispatch({ type: "TOGGLE_DIFF" }); |
| 1710 | 1866 | return; |
| 1711 | 1867 | } |
| @@ -1713,10 +1869,8 @@ export default function ChatView() { · 1 unmodified line | ||
| 1713 | 1869 | dispatch({ |
| 1714 | 1870 | type: "OPEN_DIFF", |
| 1715 | 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 | 1874 | const onOpenTurnDiff = useCallback( |
| 1721 | 1875 | (turnId: string, filePath?: string) => { |
| 1722 | 1876 | if (!activeThread) return; |
| @@ -1729,6 +1883,16 @@ export default function ChatView() { · 6 unmodified lines | ||
| 1729 | 1883 | }, |
| 1730 | 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 | + ); | |
| 1732 | 1896 | |
| 1733 | 1897 | // Empty state: no active thread |
| 1734 | 1898 | if (!activeThread) { |
| @@ -1771,14 +1935,6 @@ export default function ChatView() { · 36 unmodified lines | ||
| 1771 | 1935 | onAddProjectScript={saveProjectScript} |
| 1772 | 1936 | onUpdateProjectScript={updateProjectScript} |
| 1773 | 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 | 1939 | </header> |
| 1784 | 1940 | |
| @@ -1808,6 +1964,9 @@ export default function ChatView() { · 23 unmodified lines | ||
| 1808 | 1964 | expandedWorkGroups={expandedWorkGroups} |
| 1809 | 1965 | onToggleWorkGroup={onToggleWorkGroup} |
| 1810 | 1966 | onOpenTurnDiff={onOpenTurnDiff} |
| 1967 | + revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} | |
| 1968 | + onRevertUserMessage={onRevertUserMessage} | |
| 1969 | + isRevertingCheckpoint={isRevertingCheckpoint} | |
| 1811 | 1970 | onImageExpand={onExpandTimelineImage} |
| 1812 | 1971 | messagesEndRef={messagesEndRef} |
| 1813 | 1972 | /> |
| @@ -2114,12 +2273,6 @@ interface ChatHeaderProps { · 300 unmodified lines | ||
| 2114 | 2273 | onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>; |
| 2115 | 2274 | onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>; |
| 2116 | 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 | } |
| 2124 | 2277 | |
| 2125 | 2278 | const ChatHeader = memo(function ChatHeader({ |
| @@ -2135,12 +2288,6 @@ const ChatHeader = memo(function ChatHeader({ · 9 unmodified lines | ||
| 2135 | 2288 | onAddProjectScript, |
| 2136 | 2289 | onUpdateProjectScript, |
| 2137 | 2290 | onToggleDiff, |
| 2138 | − checkpoints, | |
| 2139 | − canCheckpoint, | |
| 2140 | − isLoadingCheckpoints, | |
| 2141 | − isRevertingCheckpoint, | |
| 2142 | − onOpenCheckpoints, | |
| 2143 | − onRevertToCheckpoint, | |
| 2144 | 2291 | }: ChatHeaderProps) { |
| 2145 | 2292 | return ( |
| 2146 | 2293 | <> |
| @@ -2163,56 +2310,14 @@ const ChatHeader = memo(function ChatHeader({ · 16 unmodified lines | ||
| 2163 | 2310 | )} |
| 2164 | 2311 | {activeProjectName && <OpenInPicker keybindings={keybindings} />} |
| 2165 | 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 | 2313 | <Button |
| 2207 | − size="xs" | |
| 2208 | − variant="ghost" | |
| 2209 | − className={cn( | |
| 2210 | − "text-muted-foreground/70 hover:text-foreground/80", | |
| 2211 | − diffOpen && "bg-accent text-accent-foreground", | |
| 2212 | − )} | |
| 2314 | + aria-label="Toggle diff panel" | |
| 2315 | + size="icon-xs" | |
| 2316 | + variant="outline" | |
| 2317 | + className={cn(diffOpen && "bg-accent text-accent-foreground")} | |
| 2213 | 2318 | onClick={onToggleDiff} |
| 2214 | 2319 | > |
| 2215 | − Diff | |
| 2320 | + <DiffIcon /> | |
| 2216 | 2321 | </Button> |
| 2217 | 2322 | </div> |
| 2218 | 2323 | </> |
| @@ -2317,6 +2422,9 @@ interface MessagesTimelineProps { · 98 unmodified lines | ||
| 2317 | 2422 | expandedWorkGroups: Record<string, boolean>; |
| 2318 | 2423 | onToggleWorkGroup: (groupId: string) => void; |
| 2319 | 2424 | onOpenTurnDiff: (turnId: string, filePath?: string) => void; |
| 2425 | + revertTurnCountByUserMessageId: Map<string, number>; | |
| 2426 | + onRevertUserMessage: (messageId: string) => void; | |
| 2427 | + isRevertingCheckpoint: boolean; | |
| 2320 | 2428 | onImageExpand: (image: ExpandedImagePreview) => void; |
| 2321 | 2429 | messagesEndRef: RefObject<HTMLDivElement | null>; |
| 2322 | 2430 | } |
| @@ -2333,6 +2441,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 10 unmodified lines | ||
| 2333 | 2441 | expandedWorkGroups, |
| 2334 | 2442 | onToggleWorkGroup, |
| 2335 | 2443 | onOpenTurnDiff, |
| 2444 | + revertTurnCountByUserMessageId, | |
| 2445 | + onRevertUserMessage, | |
| 2446 | + isRevertingCheckpoint, | |
| 2336 | 2447 | onImageExpand, |
| 2337 | 2448 | messagesEndRef, |
| 2338 | 2449 | }: MessagesTimelineProps) { |
| @@ -2433,6 +2544,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 94 unmodified lines | ||
| 2433 | 2544 | |
| 2434 | 2545 | if (timelineEntry.message.role === "user") { |
| 2435 | 2546 | const userImages = timelineEntry.message.attachments ?? []; |
| 2547 | + const canRevertAgentWork = revertTurnCountByUserMessageId.has(timelineEntry.message.id); | |
| 2436 | 2548 | return ( |
| 2437 | 2549 | <Fragment key={timelineEntry.id}> |
| 2438 | 2550 | <div className="flex justify-end"> |
| @@ -2467,9 +2579,22 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 28 unmodified lines | ||
| 2467 | 2579 | {timelineEntry.message.text} |
| 2468 | 2580 | </pre> |
| 2469 | 2581 | )} |
| 2470 | − <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30"> | |
| 2471 | − {formatTimestamp(timelineEntry.message.createdAt)} | |
| 2472 | − </p> | |
| 2582 | + <div className="mt-1.5 flex items-center justify-end gap-2"> | |
| 2583 | + {canRevertAgentWork && ( | |
| 2584 | + <Button | |
| 2585 | + type="button" | |
| 2586 | + size="xs" | |
| 2587 | + variant="outline" | |
| 2588 | + disabled={isRevertingCheckpoint || isWorking} | |
| 2589 | + onClick={() => onRevertUserMessage(timelineEntry.message.id)} | |
| 2590 | + > | |
| 2591 | + {isRevertingCheckpoint ? "Reverting..." : "Revert agent work"} | |
| 2592 | + </Button> | |
| 2593 | + )} | |
| 2594 | + <p className="text-right text-[10px] text-muted-foreground/30"> | |
| 2595 | + {formatTimestamp(timelineEntry.message.createdAt)} | |
| 2596 | + </p> | |
| 2597 | + </div> | |
| 2473 | 2598 | </div> |
| 2474 | 2599 | </div> |
| 2475 | 2600 | </Fragment> |
| @@ -2497,11 +2622,45 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 21 unmodified lines | ||
| 2497 | 2622 | {(() => { |
| 2498 | 2623 | const turnSummary = turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id); |
| 2499 | 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 | 2650 | return ( |
| 2501 | 2651 | <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5"> |
| 2502 | 2652 | <div className="mb-1.5 flex items-center justify-between gap-2"> |
| 2503 | 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 | 2664 | </p> |
| 2506 | 2665 | <Button |
| 2507 | 2666 | type="button" |
| @@ -2512,18 +2671,48 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 4 unmodified lines | ||
| 2512 | 2671 | View diff |
| 2513 | 2672 | </Button> |
| 2514 | 2673 | </div> |
| 2515 | − <div className="flex flex-wrap gap-1.5"> | |
| 2516 | − {turnSummary.files.map((file) => ( | |
| 2517 | − <button | |
| 2518 | − key={`${turnSummary.turnId}:${file.path}`} | |
| 2519 | − type="button" | |
| 2520 | − className="rounded-md border border-border/70 bg-background/70 px-2 py-1 font-mono text-[11px] text-muted-foreground/80 transition-colors hover:border-border hover:text-foreground/90" | |
| 2521 | − onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)} | |
| 2522 | − > | |
| 2523 | − {file.path} | |
| 2524 | − </button> | |
| 2525 | − ))} | |
| 2526 | − </div> | |
| 2674 | + {isCheckpointDiffLoading && ( | |
| 2675 | + <p className="mb-1.5 text-[11px] text-muted-foreground/70"> | |
| 2676 | + Loading checkpoint diff... | |
| 2677 | + </p> | |
| 2678 | + )} | |
| 2679 | + {turnSummary.files.length > 0 ? ( | |
| 2680 | + <div className="flex flex-wrap gap-1.5"> | |
| 2681 | + {turnSummary.files.map((file) => ( | |
| 2682 | + <button | |
| 2683 | + key={`${turnSummary.turnId}:${file.path}`} | |
| 2684 | + type="button" | |
| 2685 | + className="rounded-md border border-border/70 bg-background/70 px-2 py-1 font-mono text-[11px] text-muted-foreground/80 transition-colors hover:border-border hover:text-foreground/90" | |
| 2686 | + onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)} | |
| 2687 | + > | |
| 2688 | + {(() => { | |
| 2689 | + const stat = | |
| 2690 | + typeof file.additions === "number" && | |
| 2691 | + typeof file.deletions === "number" | |
| 2692 | + ? { additions: file.additions, deletions: file.deletions } | |
| 2693 | + : file.diff | |
| 2694 | + ? countDiffStat(file.diff) | |
| 2695 | + : null; | |
| 2696 | + if (!stat) { | |
| 2697 | + return file.path; | |
| 2698 | + } | |
| 2699 | + return ( | |
| 2700 | + <> | |
| 2701 | + <span>{file.path}</span> | |
| 2702 | + <span className="ml-1 text-muted-foreground/70">(</span> | |
| 2703 | + <span className="text-success">+{stat.additions}</span> | |
| 2704 | + <span className="mx-0.5 text-muted-foreground/70">/</span> | |
| 2705 | + <span className="text-destructive">-{stat.deletions}</span> | |
| 2706 | + <span className="text-muted-foreground/70">)</span> | |
| 2707 | + </> | |
| 2708 | + ); | |
| 2709 | + })()} | |
| 2710 | + </button> | |
| 2711 | + ))} | |
| 2712 | + </div> | |
| 2713 | + ) : !isCheckpointDiffLoading ? ( | |
| 2714 | + <p className="text-[11px] text-muted-foreground/70">No changed files.</p> | |
| 2715 | + ) : null} | |
| 2527 | 2716 | </div> |
| 2528 | 2717 | ); |
| 2529 | 2718 | })()} |
| @@ -1,62 +1,473 @@ | ||
| 1 | −import { PatchDiff } from "@pierre/diffs/react"; | |
| 2 | −import { XIcon } from "lucide-react"; | |
| 3 | −import { useMemo } from "react"; | |
| 1 | +import { parsePatchFiles } from "@pierre/diffs"; | |
| 2 | +import { FileDiff, type FileDiffMetadata } from "@pierre/diffs/react"; | |
| 3 | +import { Columns2Icon, Rows3Icon, XIcon } from "lucide-react"; | |
| 4 | +import { useEffect, useMemo, useRef, useState } from "react"; | |
| 4 | 5 | import { isElectron } from "../env"; |
| 5 | −import { deriveTurnDiffSummaries, formatTimestamp } from "../session-logic"; | |
| 6 | +import { useNativeApi } from "../hooks/useNativeApi"; | |
| 7 | +import { useTheme } from "../hooks/useTheme"; | |
| 8 | +import { | |
| 9 | + deriveTurnDiffSummaries, | |
| 10 | + formatTimestamp, | |
| 11 | + inferCheckpointTurnCountByTurnId, | |
| 12 | +} from "../session-logic"; | |
| 6 | 13 | import { useStore } from "../store"; |
| 7 | 14 | import { Button } from "./ui/button"; |
| 15 | +import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; | |
| 8 | 16 | import { cn } from "~/lib/utils"; |
| 9 | 17 | |
| 10 | −export default function DiffPanel() { | |
| 18 | +type DiffRenderMode = "stacked" | "split"; | |
| 19 | + | |
| 20 | +type RenderablePatch = | |
| 21 | + | { | |
| 22 | + kind: "files"; | |
| 23 | + files: FileDiffMetadata[]; | |
| 24 | + } | |
| 25 | + | { | |
| 26 | + kind: "raw"; | |
| 27 | + text: string; | |
| 28 | + reason: string; | |
| 29 | + }; | |
| 30 | + | |
| 31 | +function getRenderablePatch(patch: string | undefined): RenderablePatch | null { | |
| 32 | + if (!patch) return null; | |
| 33 | + const normalizedPatch = patch.trim(); | |
| 34 | + if (normalizedPatch.length === 0) return null; | |
| 35 | + | |
| 36 | + try { | |
| 37 | + const parsedPatches = parsePatchFiles(normalizedPatch, "diff-panel"); | |
| 38 | + const files = parsedPatches.flatMap((parsedPatch) => parsedPatch.files); | |
| 39 | + if (files.length > 0) { | |
| 40 | + return { kind: "files", files }; | |
| 41 | + } | |
| 42 | + | |
| 43 | + return { | |
| 44 | + kind: "raw", | |
| 45 | + text: normalizedPatch, | |
| 46 | + reason: "Unsupported diff format. Showing raw patch.", | |
| 47 | + }; | |
| 48 | + } catch { | |
| 49 | + return { | |
| 50 | + kind: "raw", | |
| 51 | + text: normalizedPatch, | |
| 52 | + reason: "Failed to parse patch. Showing raw patch.", | |
| 53 | + }; | |
| 54 | + } | |
| 55 | +} | |
| 56 | + | |
| 57 | +function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { | |
| 58 | + const raw = fileDiff.name ?? fileDiff.prevName ?? ""; | |
| 59 | + if (raw.startsWith("a/") || raw.startsWith("b/")) { | |
| 60 | + return raw.slice(2); | |
| 61 | + } | |
| 62 | + return raw; | |
| 63 | +} | |
| 64 | + | |
| 65 | +function buildCheckpointDiffKey( | |
| 66 | + sessionId: string, | |
| 67 | + fromTurnCount: number, | |
| 68 | + toTurnCount: number, | |
| 69 | +): string { | |
| 70 | + return `${sessionId}:${fromTurnCount}-${toTurnCount}`; | |
| 71 | +} | |
| 72 | + | |
| 73 | +interface DiffPanelProps { | |
| 74 | + mode?: "inline" | "sheet"; | |
| 75 | +} | |
| 76 | + | |
| 77 | +export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | |
| 78 | + const api = useNativeApi(); | |
| 79 | + const { resolvedTheme } = useTheme(); | |
| 11 | 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 | 86 | const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId); |
| 13 | 87 | const turnDiffSummaries = useMemo( |
| 14 | − () => deriveTurnDiffSummaries(activeThread?.events ?? []), | |
| 15 | − [activeThread?.events], | |
| 88 | + () => | |
| 89 | + activeThread?.turnDiffSummaries.length | |
| 90 | + ? activeThread.turnDiffSummaries | |
| 91 | + : deriveTurnDiffSummaries(activeThread?.events ?? []), | |
| 92 | + [activeThread?.events, activeThread?.turnDiffSummaries], | |
| 16 | 93 | ); |
| 94 | + const inferredCheckpointTurnCountByTurnId = useMemo( | |
| 95 | + () => inferCheckpointTurnCountByTurnId(turnDiffSummaries), | |
| 96 | + [turnDiffSummaries], | |
| 97 | + ); | |
| 98 | + | |
| 99 | + useEffect(() => { | |
| 100 | + setCheckpointDiffByKey({}); | |
| 101 | + setCheckpointDiffError(null); | |
| 102 | + }, [activeThread?.id]); | |
| 17 | 103 | |
| 18 | 104 | const canApplyStoredTarget = Boolean(activeThread && state.diffThreadId === activeThread.id); |
| 19 | 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 | 108 | const selectedTurn = |
| 22 | − turnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? turnDiffSummaries[0]; | |
| 23 | − const selectedFile = | |
| 24 | − selectedTurn?.files.find((file) => file.path === selectedFilePath) ?? selectedTurn?.files[0]; | |
| 25 | − const selectedPatch = selectedFile?.diff ?? selectedTurn?.unifiedDiff; | |
| 109 | + selectedTurnId === null | |
| 110 | + ? undefined | |
| 111 | + : (turnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? | |
| 112 | + turnDiffSummaries[0]); | |
| 113 | + const selectedCheckpointTurnCount = | |
| 114 | + selectedTurn && | |
| 115 | + (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]); | |
| 116 | + const selectedCheckpointRange = useMemo( | |
| 117 | + () => | |
| 118 | + typeof selectedCheckpointTurnCount === "number" | |
| 119 | + ? { | |
| 120 | + fromTurnCount: Math.max(0, selectedCheckpointTurnCount - 1), | |
| 121 | + toTurnCount: selectedCheckpointTurnCount, | |
| 122 | + } | |
| 123 | + : null, | |
| 124 | + [selectedCheckpointTurnCount], | |
| 125 | + ); | |
| 126 | + const conversationCheckpointTurnCount = useMemo(() => { | |
| 127 | + const turnCounts = turnDiffSummaries | |
| 128 | + .map( | |
| 129 | + (summary) => | |
| 130 | + summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId], | |
| 131 | + ) | |
| 132 | + .filter((value): value is number => typeof value === "number"); | |
| 133 | + if (turnCounts.length === 0) { | |
| 134 | + return undefined; | |
| 135 | + } | |
| 136 | + const latest = Math.max(...turnCounts); | |
| 137 | + return latest > 0 ? latest : undefined; | |
| 138 | + }, [inferredCheckpointTurnCountByTurnId, turnDiffSummaries]); | |
| 139 | + const conversationCheckpointRange = useMemo( | |
| 140 | + () => | |
| 141 | + !selectedTurn && typeof conversationCheckpointTurnCount === "number" | |
| 142 | + ? { | |
| 143 | + fromTurnCount: 0, | |
| 144 | + toTurnCount: conversationCheckpointTurnCount, | |
| 145 | + } | |
| 146 | + : null, | |
| 147 | + [conversationCheckpointTurnCount, selectedTurn], | |
| 148 | + ); | |
| 149 | + const selectedCheckpointDiffKey = | |
| 150 | + activeThread?.session?.sessionId && selectedCheckpointRange | |
| 151 | + ? buildCheckpointDiffKey( | |
| 152 | + activeThread.session.sessionId, | |
| 153 | + selectedCheckpointRange.fromTurnCount, | |
| 154 | + selectedCheckpointRange.toTurnCount, | |
| 155 | + ) | |
| 156 | + : null; | |
| 157 | + const conversationCheckpointDiffKey = | |
| 158 | + activeThread?.session?.sessionId && conversationCheckpointRange | |
| 159 | + ? buildCheckpointDiffKey( | |
| 160 | + activeThread.session.sessionId, | |
| 161 | + conversationCheckpointRange.fromTurnCount, | |
| 162 | + conversationCheckpointRange.toTurnCount, | |
| 163 | + ) | |
| 164 | + : null; | |
| 165 | + const selectedTurnCheckpointDiff = | |
| 166 | + selectedCheckpointDiffKey === null ? undefined : checkpointDiffByKey[selectedCheckpointDiffKey]; | |
| 167 | + const conversationCheckpointDiff = | |
| 168 | + conversationCheckpointDiffKey === null | |
| 169 | + ? undefined | |
| 170 | + : checkpointDiffByKey[conversationCheckpointDiffKey]; | |
| 171 | + const selectedPatch = useMemo(() => { | |
| 172 | + const patchForSummary = (summary: (typeof turnDiffSummaries)[number]): string | undefined => { | |
| 173 | + const checkpointTurnCount = | |
| 174 | + summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; | |
| 175 | + if (activeThread?.session?.sessionId && typeof checkpointTurnCount === "number") { | |
| 176 | + const checkpointPatch = | |
| 177 | + checkpointDiffByKey[ | |
| 178 | + buildCheckpointDiffKey( | |
| 179 | + activeThread.session.sessionId, | |
| 180 | + Math.max(0, checkpointTurnCount - 1), | |
| 181 | + checkpointTurnCount, | |
| 182 | + ) | |
| 183 | + ]; | |
| 184 | + if (checkpointPatch) { | |
| 185 | + return checkpointPatch; | |
| 186 | + } | |
| 187 | + } | |
| 188 | + if (summary.unifiedDiff) { | |
| 189 | + return summary.unifiedDiff; | |
| 190 | + } | |
| 191 | + const filePatches = summary.files | |
| 192 | + .map((file) => file.diff?.trim()) | |
| 193 | + .filter((patch): patch is string => Boolean(patch)); | |
| 194 | + if (filePatches.length === 0) { | |
| 195 | + return undefined; | |
| 196 | + } | |
| 197 | + return filePatches.join("\n\n"); | |
| 198 | + }; | |
| 199 | + | |
| 200 | + if (selectedTurn) { | |
| 201 | + return selectedTurnCheckpointDiff ?? patchForSummary(selectedTurn); | |
| 202 | + } | |
| 203 | + | |
| 204 | + if (conversationCheckpointDiff) { | |
| 205 | + return conversationCheckpointDiff; | |
| 206 | + } | |
| 207 | + | |
| 208 | + // Fallback when a conversation checkpoint diff isn't available yet: | |
| 209 | + // keep one patch per file path (latest change wins) so files aren't duplicated. | |
| 210 | + const latestPatchByPath = new Map<string, string>(); | |
| 211 | + for (const summary of turnDiffSummaries) { | |
| 212 | + for (const file of summary.files) { | |
| 213 | + if (latestPatchByPath.has(file.path)) { | |
| 214 | + continue; | |
| 215 | + } | |
| 216 | + const patch = file.diff?.trim(); | |
| 217 | + if (!patch) { | |
| 218 | + continue; | |
| 219 | + } | |
| 220 | + latestPatchByPath.set(file.path, patch); | |
| 221 | + } | |
| 222 | + } | |
| 223 | + if (latestPatchByPath.size > 0) { | |
| 224 | + return Array.from(latestPatchByPath.entries()) | |
| 225 | + .toSorted(([leftPath], [rightPath]) => | |
| 226 | + leftPath.localeCompare(rightPath, undefined, { numeric: true, sensitivity: "base" }), | |
| 227 | + ) | |
| 228 | + .map(([, patch]) => patch) | |
| 229 | + .join("\n\n"); | |
| 230 | + } | |
| 231 | + | |
| 232 | + const patches = turnDiffSummaries | |
| 233 | + .toReversed() | |
| 234 | + .map((summary) => patchForSummary(summary)?.trim()) | |
| 235 | + .filter((patch): patch is string => Boolean(patch)); | |
| 236 | + if (patches.length === 0) { | |
| 237 | + return undefined; | |
| 238 | + } | |
| 239 | + return patches.join("\n\n"); | |
| 240 | + }, [ | |
| 241 | + activeThread?.session?.sessionId, | |
| 242 | + checkpointDiffByKey, | |
| 243 | + conversationCheckpointDiff, | |
| 244 | + inferredCheckpointTurnCountByTurnId, | |
| 245 | + selectedTurn, | |
| 246 | + selectedTurnCheckpointDiff, | |
| 247 | + turnDiffSummaries, | |
| 248 | + ]); | |
| 249 | + const renderablePatch = useMemo(() => getRenderablePatch(selectedPatch), [selectedPatch]); | |
| 250 | + const renderableFiles = useMemo(() => { | |
| 251 | + if (!renderablePatch || renderablePatch.kind !== "files") { | |
| 252 | + return []; | |
| 253 | + } | |
| 254 | + return renderablePatch.files.toSorted((left, right) => | |
| 255 | + resolveFileDiffPath(left).localeCompare(resolveFileDiffPath(right), undefined, { | |
| 256 | + numeric: true, | |
| 257 | + sensitivity: "base", | |
| 258 | + }), | |
| 259 | + ); | |
| 260 | + }, [renderablePatch]); | |
| 261 | + | |
| 262 | + useEffect(() => { | |
| 263 | + const checkpointFetchRange = selectedTurn | |
| 264 | + ? selectedCheckpointRange | |
| 265 | + : conversationCheckpointRange; | |
| 266 | + const checkpointFetchKey = selectedTurn | |
| 267 | + ? selectedCheckpointDiffKey | |
| 268 | + : conversationCheckpointDiffKey; | |
| 269 | + if (!api || !activeThread?.session?.sessionId || !checkpointFetchRange || !checkpointFetchKey) { | |
| 270 | + return; | |
| 271 | + } | |
| 272 | + if (checkpointDiffByKey[checkpointFetchKey] !== undefined) { | |
| 273 | + return; | |
| 274 | + } | |
| 275 | + | |
| 276 | + let cancelled = false; | |
| 277 | + setIsLoadingCheckpointDiff(true); | |
| 278 | + setCheckpointDiffError(null); | |
| 279 | + void api.providers | |
| 280 | + .getCheckpointDiff({ | |
| 281 | + sessionId: activeThread.session.sessionId, | |
| 282 | + fromTurnCount: checkpointFetchRange.fromTurnCount, | |
| 283 | + toTurnCount: checkpointFetchRange.toTurnCount, | |
| 284 | + }) | |
| 285 | + .then((result) => { | |
| 286 | + if (cancelled) return; | |
| 287 | + setCheckpointDiffByKey((previous) => ({ | |
| 288 | + ...previous, | |
| 289 | + [checkpointFetchKey]: result.diff, | |
| 290 | + })); | |
| 291 | + if (selectedTurn) { | |
| 292 | + dispatch({ | |
| 293 | + type: "SET_THREAD_TURN_CHECKPOINT_DIFFS", | |
| 294 | + threadId: activeThread.id, | |
| 295 | + checkpointDiffByTurnId: { | |
| 296 | + [selectedTurn.turnId]: result.diff, | |
| 297 | + }, | |
| 298 | + }); | |
| 299 | + } | |
| 300 | + }) | |
| 301 | + .catch((error) => { | |
| 302 | + if (cancelled) return; | |
| 303 | + setCheckpointDiffError( | |
| 304 | + error instanceof Error ? error.message : "Failed to load checkpoint diff.", | |
| 305 | + ); | |
| 306 | + }) | |
| 307 | + .finally(() => { | |
| 308 | + if (cancelled) return; | |
| 309 | + setIsLoadingCheckpointDiff(false); | |
| 310 | + }); | |
| 311 | + | |
| 312 | + return () => { | |
| 313 | + cancelled = true; | |
| 314 | + }; | |
| 315 | + }, [ | |
| 316 | + activeThread?.id, | |
| 317 | + activeThread?.session?.sessionId, | |
| 318 | + api, | |
| 319 | + checkpointDiffByKey, | |
| 320 | + conversationCheckpointDiffKey, | |
| 321 | + conversationCheckpointRange, | |
| 322 | + dispatch, | |
| 323 | + selectedCheckpointDiffKey, | |
| 324 | + selectedCheckpointRange, | |
| 325 | + selectedTurn, | |
| 326 | + ]); | |
| 327 | + | |
| 328 | + useEffect(() => { | |
| 329 | + if (!selectedFilePath || !patchViewportRef.current) { | |
| 330 | + return; | |
| 331 | + } | |
| 332 | + const target = Array.from( | |
| 333 | + patchViewportRef.current.querySelectorAll<HTMLElement>("[data-diff-file-path]"), | |
| 334 | + ).find((element) => element.dataset.diffFilePath === selectedFilePath); | |
| 335 | + target?.scrollIntoView({ block: "nearest" }); | |
| 336 | + }, [selectedFilePath, renderableFiles]); | |
| 26 | 337 | |
| 27 | 338 | const selectTurn = (turnId: string) => { |
| 28 | 339 | if (!activeThread) return; |
| 29 | − const turn = turnDiffSummaries.find((summary) => summary.turnId === turnId); | |
| 30 | 340 | dispatch({ |
| 31 | 341 | type: "SET_DIFF_TARGET", |
| 32 | 342 | threadId: activeThread.id, |
| 33 | 343 | turnId, |
| 34 | − ...(turn?.files[0]?.path ? { filePath: turn.files[0].path } : {}), | |
| 35 | 344 | }); |
| 36 | 345 | }; |
| 37 | − | |
| 38 | − const selectFile = (filePath: string) => { | |
| 39 | − if (!activeThread || !selectedTurn) return; | |
| 346 | + const selectWholeConversation = () => { | |
| 347 | + if (!activeThread) return; | |
| 40 | 348 | dispatch({ |
| 41 | 349 | type: "SET_DIFF_TARGET", |
| 42 | 350 | threadId: activeThread.id, |
| 43 | − turnId: selectedTurn.turnId, | |
| 44 | − filePath, | |
| 45 | 351 | }); |
| 46 | 352 | }; |
| 47 | 353 | |
| 354 | + const shouldUseDragRegion = isElectron && mode === "inline"; | |
| 355 | + | |
| 48 | 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 | 365 | <div |
| 51 | 366 | className={cn( |
| 52 | 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> | |
| 57 | − <Button type="button" size="icon-xs" variant="ghost" onClick={() => dispatch({ type: "CLOSE_DIFF" })}> | |
| 58 | − <XIcon /> | |
| 59 | − </Button> | |
| 371 | + <div className="flex gap-1.5 overflow-x-auto pb-0.5"> | |
| 372 | + <button type="button" className="shrink-0 rounded-md" onClick={selectWholeConversation}> | |
| 373 | + <div | |
| 374 | + className={cn( | |
| 375 | + "rounded-md border px-2 py-1 text-left transition-colors", | |
| 376 | + selectedTurnId === null | |
| 377 | + ? "border-border bg-accent text-accent-foreground" | |
| 378 | + : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80", | |
| 379 | + )} | |
| 380 | + > | |
| 381 | + <div className="text-[10px] font-medium">All turns</div> | |
| 382 | + <div className="text-[8px] opacity-70">Conversation</div> | |
| 383 | + </div> | |
| 384 | + </button> | |
| 385 | + {turnDiffSummaries.map((summary, index) => ( | |
| 386 | + <button | |
| 387 | + key={summary.turnId} | |
| 388 | + type="button" | |
| 389 | + className="shrink-0 rounded-md" | |
| 390 | + onClick={() => selectTurn(summary.turnId)} | |
| 391 | + title={summary.turnId} | |
| 392 | + > | |
| 393 | + <div | |
| 394 | + className={cn( | |
| 395 | + "rounded-md border px-2 py-1 text-left transition-colors", | |
| 396 | + summary.turnId === selectedTurn?.turnId | |
| 397 | + ? "border-border bg-accent text-accent-foreground" | |
| 398 | + : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80", | |
| 399 | + )} | |
| 400 | + > | |
| 401 | + <div className="text-[10px] font-medium"> | |
| 402 | + Turn {turnDiffSummaries.length - index} | |
| 403 | + </div> | |
| 404 | + <div className="text-[8px] opacity-70">{formatTimestamp(summary.completedAt)}</div> | |
| 405 | + </div> | |
| 406 | + </button> | |
| 407 | + ))} | |
| 408 | + </div> | |
| 409 | + <div className="flex items-center gap-1"> | |
| 410 | + <Popover> | |
| 411 | + <PopoverTrigger | |
| 412 | + openOnHover | |
| 413 | + render={ | |
| 414 | + <Button | |
| 415 | + aria-label="Stacked diff view" | |
| 416 | + aria-pressed={diffRenderMode === "stacked"} | |
| 417 | + size="icon-xs" | |
| 418 | + variant="outline" | |
| 419 | + className={cn(diffRenderMode === "stacked" && "bg-accent text-accent-foreground")} | |
| 420 | + onClick={() => setDiffRenderMode("stacked")} | |
| 421 | + /> | |
| 422 | + } | |
| 423 | + > | |
| 424 | + <Rows3Icon /> | |
| 425 | + </PopoverTrigger> | |
| 426 | + <PopoverPopup | |
| 427 | + tooltipStyle | |
| 428 | + side="bottom" | |
| 429 | + sideOffset={6} | |
| 430 | + align="center" | |
| 431 | + className="pointer-events-none select-none" | |
| 432 | + > | |
| 433 | + Stacked | |
| 434 | + </PopoverPopup> | |
| 435 | + </Popover> | |
| 436 | + <Popover> | |
| 437 | + <PopoverTrigger | |
| 438 | + openOnHover | |
| 439 | + render={ | |
| 440 | + <Button | |
| 441 | + aria-label="Split diff view" | |
| 442 | + aria-pressed={diffRenderMode === "split"} | |
| 443 | + size="icon-xs" | |
| 444 | + variant="outline" | |
| 445 | + className={cn(diffRenderMode === "split" && "bg-accent text-accent-foreground")} | |
| 446 | + onClick={() => setDiffRenderMode("split")} | |
| 447 | + /> | |
| 448 | + } | |
| 449 | + > | |
| 450 | + <Columns2Icon /> | |
| 451 | + </PopoverTrigger> | |
| 452 | + <PopoverPopup | |
| 453 | + tooltipStyle | |
| 454 | + side="bottom" | |
| 455 | + sideOffset={6} | |
| 456 | + align="center" | |
| 457 | + className="pointer-events-none select-none" | |
| 458 | + > | |
| 459 | + Split | |
| 460 | + </PopoverPopup> | |
| 461 | + </Popover> | |
| 462 | + <Button | |
| 463 | + type="button" | |
| 464 | + size="icon-xs" | |
| 465 | + variant="ghost" | |
| 466 | + onClick={() => dispatch({ type: "CLOSE_DIFF" })} | |
| 467 | + > | |
| 468 | + <XIcon /> | |
| 469 | + </Button> | |
| 470 | + </div> | |
| 60 | 471 | </div> |
| 61 | 472 | |
| 62 | 473 | {!activeThread ? ( |
| @@ -65,78 +476,52 @@ export default function DiffPanel() { · 2 unmodified lines | ||
| 65 | 476 | </div> |
| 66 | 477 | ) : turnDiffSummaries.length === 0 ? ( |
| 67 | 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 | 480 | </div> |
| 70 | 481 | ) : ( |
| 71 | 482 | <> |
| 72 | − <div className="border-b border-border px-3 py-2.5"> | |
| 73 | − <p className="mb-1.5 text-[10px] uppercase tracking-[0.12em] text-muted-foreground/60"> | |
| 74 | − Turns | |
| 75 | − </p> | |
| 76 | − <div className="flex flex-wrap gap-1.5"> | |
| 77 | − {turnDiffSummaries.map((summary, index) => ( | |
| 78 | − <button | |
| 79 | − key={summary.turnId} | |
| 80 | − type="button" | |
| 81 | − className={cn( | |
| 82 | − "rounded-md border px-2 py-1 text-left transition-colors", | |
| 83 | − summary.turnId === selectedTurn?.turnId | |
| 84 | − ? "border-border bg-accent text-accent-foreground" | |
| 85 | − : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80", | |
| 86 | − )} | |
| 87 | − onClick={() => selectTurn(summary.turnId)} | |
| 88 | − title={summary.turnId} | |
| 89 | − > | |
| 90 | − <div className="text-[10px] font-medium">Turn {turnDiffSummaries.length - index}</div> | |
| 91 | − <div className="text-[10px] opacity-70">{formatTimestamp(summary.completedAt)}</div> | |
| 92 | − </button> | |
| 93 | − ))} | |
| 94 | − </div> | |
| 95 | − </div> | |
| 96 | − | |
| 97 | − <div className="min-h-0 flex flex-1"> | |
| 98 | − <div className="w-[220px] shrink-0 border-r border-border px-2 py-2"> | |
| 99 | − <p className="mb-1.5 px-1 text-[10px] uppercase tracking-[0.12em] text-muted-foreground/60"> | |
| 100 | − Files | |
| 483 | + <div ref={patchViewportRef} className="min-w-0 flex-1 overflow-auto px-3 py-2"> | |
| 484 | + {!canApplyStoredTarget && state.diffThreadId && ( | |
| 485 | + <p className="mb-2 text-[11px] text-muted-foreground/65"> | |
| 486 | + Showing diffs for the active thread. | |
| 101 | 487 | </p> |
| 102 | − <div className="space-y-1 overflow-y-auto"> | |
| 103 | − {selectedTurn?.files.map((file) => ( | |
| 104 | − <button | |
| 105 | − key={`${selectedTurn.turnId}:${file.path}`} | |
| 106 | − type="button" | |
| 107 | − className={cn( | |
| 108 | − "flex w-full items-center justify-between gap-2 rounded-md border px-2 py-1.5 text-left font-mono text-[11px] transition-colors", | |
| 109 | − selectedFile?.path === file.path | |
| 110 | − ? "border-border bg-accent text-accent-foreground" | |
| 111 | − : "border-border/60 bg-background/60 text-muted-foreground/85 hover:border-border hover:text-foreground/90", | |
| 112 | − )} | |
| 113 | − onClick={() => selectFile(file.path)} | |
| 488 | + )} | |
| 489 | + {checkpointDiffError && !renderablePatch && ( | |
| 490 | + <p className="mb-2 text-[11px] text-red-500/80">{checkpointDiffError}</p> | |
| 491 | + )} | |
| 492 | + {!renderablePatch ? ( | |
| 493 | + <div className="flex h-full items-center justify-center text-xs text-muted-foreground/70"> | |
| 494 | + {isLoadingCheckpointDiff | |
| 495 | + ? "Loading checkpoint diff..." | |
| 496 | + : "No patch available for this selection."} | |
| 497 | + </div> | |
| 498 | + ) : renderablePatch.kind === "files" ? ( | |
| 499 | + <div className="space-y-3"> | |
| 500 | + {renderableFiles.map((fileDiff) => ( | |
| 501 | + <div | |
| 502 | + key={fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`} | |
| 503 | + data-diff-file-path={resolveFileDiffPath(fileDiff)} | |
| 504 | + className="rounded-md" | |
| 114 | 505 | > |
| 115 | − <span className="truncate">{file.path}</span> | |
| 116 | − {file.kind && ( | |
| 117 | − <span className="rounded-sm border border-border/60 px-1 py-0.5 text-[9px] uppercase tracking-[0.08em]"> | |
| 118 | − {file.kind} | |
| 119 | − </span> | |
| 120 | − )} | |
| 121 | − </button> | |
| 506 | + <FileDiff | |
| 507 | + fileDiff={fileDiff} | |
| 508 | + options={{ | |
| 509 | + diffStyle: diffRenderMode === "split" ? "split" : "unified", | |
| 510 | + lineDiffType: "none", | |
| 511 | + themeType: resolvedTheme, | |
| 512 | + }} | |
| 513 | + /> | |
| 514 | + </div> | |
| 122 | 515 | ))} |
| 123 | 516 | </div> |
| 124 | − </div> | |
| 125 | − | |
| 126 | − <div className="min-w-0 flex-1 overflow-auto px-3 py-2"> | |
| 127 | − {!canApplyStoredTarget && state.diffThreadId && ( | |
| 128 | − <p className="mb-2 text-[11px] text-muted-foreground/65"> | |
| 129 | − Showing diffs for the active thread. | |
| 130 | − </p> | |
| 131 | − )} | |
| 132 | − {!selectedPatch ? ( | |
| 133 | − <div className="flex h-full items-center justify-center text-xs text-muted-foreground/70"> | |
| 134 | − No patch available for this selection. | |
| 135 | − </div> | |
| 136 | − ) : ( | |
| 137 | − <PatchDiff patch={selectedPatch} /> | |
| 138 | − )} | |
| 139 | − </div> | |
| 517 | + ) : ( | |
| 518 | + <div className="space-y-2"> | |
| 519 | + <p className="text-[11px] text-muted-foreground/75">{renderablePatch.reason}</p> | |
| 520 | + <pre className="max-h-[72vh] overflow-auto rounded-md border border-border/70 bg-background/70 p-3 font-mono text-[11px] leading-relaxed text-muted-foreground/90"> | |
| 521 | + {renderablePatch.text} | |
| 522 | + </pre> | |
| 523 | + </div> | |
| 524 | + )} | |
| 140 | 525 | </div> |
| 141 | 526 | </> |
| 142 | 527 | )} |
| @@ -170,6 +170,7 @@ export default function Sidebar() { · 169 unmodified lines | ||
| 170 | 170 | session: null, |
| 171 | 171 | messages: [], |
| 172 | 172 | events: [], |
| 173 | + turnDiffSummaries: [], | |
| 173 | 174 | error: null, |
| 174 | 175 | createdAt: new Date().toISOString(), |
| 175 | 176 | branch: options?.branch ?? null, |
| @@ -0,0 +1,27 @@ | ||
| 1 | +import { useEffect, useState } from "react"; | |
| 2 | + | |
| 3 | +function getMediaQueryMatch(query: string): boolean { | |
| 4 | + if (typeof window === "undefined") { | |
| 5 | + return false; | |
| 6 | + } | |
| 7 | + return window.matchMedia(query).matches; | |
| 8 | +} | |
| 9 | + | |
| 10 | +export function useMediaQuery(query: string): boolean { | |
| 11 | + const [matches, setMatches] = useState(() => getMediaQueryMatch(query)); | |
| 12 | + | |
| 13 | + useEffect(() => { | |
| 14 | + const mediaQueryList = window.matchMedia(query); | |
| 15 | + const handleChange = () => { | |
| 16 | + setMatches(mediaQueryList.matches); | |
| 17 | + }; | |
| 18 | + | |
| 19 | + setMatches(mediaQueryList.matches); | |
| 20 | + mediaQueryList.addEventListener("change", handleChange); | |
| 21 | + return () => { | |
| 22 | + mediaQueryList.removeEventListener("change", handleChange); | |
| 23 | + }; | |
| 24 | + }, [query]); | |
| 25 | + | |
| 26 | + return matches; | |
| 27 | +} | |
| @@ -263,6 +263,68 @@ describe("hydratePersistedState", () => { · 262 unmodified lines | ||
| 263 | 263 | ]); |
| 264 | 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 | }); |
| 267 | 329 | |
| 268 | 330 | describe("toPersistedState", () => { |
| @@ -309,6 +371,25 @@ describe("toPersistedState", () => { · 40 unmodified lines | ||
| 309 | 371 | lastVisitedAt: "2026-02-08T10:02:00.000Z", |
| 310 | 372 | branch: null, |
| 311 | 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 | }; |
| 313 | 394 | |
| 314 | 395 | const persisted = toPersistedState({ |
| @@ -366,6 +447,23 @@ describe("toPersistedState", () => { · 51 unmodified lines | ||
| 366 | 447 | lastVisitedAt: thread.lastVisitedAt, |
| 367 | 448 | branch: null, |
| 368 | 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 | 468 | const persistedThread = persisted.threads[0]; |
| 371 | 469 | expect(persistedThread).toBeDefined(); |
| @@ -47,6 +47,23 @@ const persistedTerminalGroupSchema = z.object({ · 46 unmodified lines | ||
| 47 | 47 | terminalIds: z.array(z.string().trim().min(1)), |
| 48 | 48 | }); |
| 49 | 49 | |
| 50 | +const persistedTurnDiffFileChangeSchema = z.object({ | |
| 51 | + path: z.string().min(1), | |
| 52 | + kind: z.string().min(1).optional(), | |
| 53 | + additions: z.number().int().min(0).optional(), | |
| 54 | + deletions: z.number().int().min(0).optional(), | |
| 55 | +}); | |
| 56 | + | |
| 57 | +const persistedTurnDiffSummarySchema = z.object({ | |
| 58 | + turnId: z.string().min(1), | |
| 59 | + completedAt: z.string().min(1), | |
| 60 | + status: z.string().min(1).optional(), | |
| 61 | + files: z.array(persistedTurnDiffFileChangeSchema), | |
| 62 | + assistantMessageId: z.string().min(1).optional(), | |
| 63 | + checkpointTurnCount: z.number().int().min(0).optional(), | |
| 64 | + checkpointDiffLoaded: z.boolean().optional(), | |
| 65 | +}); | |
| 66 | + | |
| 50 | 67 | const persistedThreadSchema = z.object({ |
| 51 | 68 | id: z.string().min(1), |
| 52 | 69 | codexThreadId: z.string().min(1).nullable().default(null), |
| @@ -67,6 +84,7 @@ const persistedThreadSchema = z.object({ · 14 unmodified lines | ||
| 67 | 84 | lastVisitedAt: z.string().min(1).optional(), |
| 68 | 85 | branch: z.string().min(1).nullable().optional(), |
| 69 | 86 | worktreePath: z.string().min(1).nullable().optional(), |
| 87 | + turnDiffSummaries: z.array(persistedTurnDiffSummarySchema).default([]), | |
| 70 | 88 | }); |
| 71 | 89 | |
| 72 | 90 | const persistedStateBodySchema = z.object({ |
| @@ -273,6 +291,22 @@ function hydrateThread( · 200 unmodified lines | ||
| 273 | 291 | lastVisitedAt: thread.lastVisitedAt, |
| 274 | 292 | branch: thread.branch ?? null, |
| 275 | 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 | } |
| 278 | 312 | |
| @@ -353,6 +387,22 @@ export function toPersistedState( · 74 unmodified lines | ||
| 353 | 387 | ...(thread.lastVisitedAt ? { lastVisitedAt: thread.lastVisitedAt } : {}), |
| 354 | 388 | branch: thread.branch, |
| 355 | 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 | 407 | activeThreadId: state.activeThreadId, |
| 358 | 408 | runtimeMode: state.runtimeMode, |
| @@ -5,6 +5,7 @@ import { · 4 unmodified lines | ||
| 5 | 5 | type WorkLogEntry, |
| 6 | 6 | applyEventToMessages, |
| 7 | 7 | derivePendingApprovals, |
| 8 | + deriveTurnDiffFilesFromUnifiedDiff, | |
| 8 | 9 | deriveTurnDiffSummaries, |
| 9 | 10 | deriveTimelineEntries, |
| 10 | 11 | deriveWorkLogEntries, |
| @@ -395,7 +396,7 @@ describe("deriveWorkLogEntries", () => { · 384 unmodified lines | ||
| 395 | 396 | }); |
| 396 | 397 | |
| 397 | 398 | describe("deriveTurnDiffSummaries", () => { |
| 398 | − it("collects per-turn file changes and links them to assistant message completion", () => { | |
| 399 | + it("tracks completed turns and links them to assistant message completion", () => { | |
| 399 | 400 | const summaries = deriveTurnDiffSummaries([ |
| 400 | 401 | makeEvent({ |
| 401 | 402 | id: "evt-turn-start", |
| @@ -492,8 +493,57 @@ describe("deriveTurnDiffSummaries", () => { · 90 unmodified lines | ||
| 492 | 493 | expect(summaries).toHaveLength(1); |
| 493 | 494 | expect(summaries[0]?.turnId).toBe("turn-1"); |
| 494 | 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[0]?.kind).toBe("modified"); | |
| 496 | + expect(summaries[0]?.files).toEqual([]); | |
| 497 | + }); | |
| 498 | + | |
| 499 | + it("includes completed turns even when no fileChange tool events were emitted", () => { | |
| 500 | + const summaries = deriveTurnDiffSummaries([ | |
| 501 | + makeEvent({ | |
| 502 | + id: "evt-turn-start", | |
| 503 | + method: "turn/started", | |
| 504 | + turnId: "turn-1", | |
| 505 | + createdAt: "2026-02-08T10:00:00.000Z", | |
| 506 | + }), | |
| 507 | + makeEvent({ | |
| 508 | + id: "evt-turn-completed", | |
| 509 | + method: "turn/completed", | |
| 510 | + turnId: "turn-1", | |
| 511 | + createdAt: "2026-02-08T10:00:01.000Z", | |
| 512 | + payload: { | |
| 513 | + turn: { | |
| 514 | + id: "turn-1", | |
| 515 | + status: "completed", | |
| 516 | + }, | |
| 517 | + }, | |
| 518 | + }), | |
| 519 | + ]); | |
| 520 | + | |
| 521 | + expect(summaries).toHaveLength(1); | |
| 522 | + expect(summaries[0]?.turnId).toBe("turn-1"); | |
| 523 | + expect(summaries[0]?.files).toEqual([]); | |
| 524 | + }); | |
| 525 | +}); | |
| 526 | + | |
| 527 | +describe("deriveTurnDiffFilesFromUnifiedDiff", () => { | |
| 528 | + it("splits a multi-file patch into sorted per-file entries", () => { | |
| 529 | + const files = deriveTurnDiffFilesFromUnifiedDiff( | |
| 530 | + [ | |
| 531 | + "diff --git a/src/b.ts b/src/b.ts", | |
| 532 | + "@@ -1 +1 @@", | |
| 533 | + "-old-b", | |
| 534 | + "+new-b", | |
| 535 | + "diff --git a/src/a.ts b/src/a.ts", | |
| 536 | + "@@ -1 +1 @@", | |
| 537 | + "-old-a", | |
| 538 | + "+new-a", | |
| 539 | + ].join("\n"), | |
| 540 | + ); | |
| 541 | + | |
| 542 | + expect(files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]); | |
| 543 | + expect(files[0]?.diff).toContain("diff --git a/src/a.ts b/src/a.ts"); | |
| 544 | + expect(files[1]?.diff).toContain("diff --git a/src/b.ts b/src/b.ts"); | |
| 545 | + expect(files[0]?.additions).toBe(1); | |
| 546 | + expect(files[0]?.deletions).toBe(1); | |
| 497 | 547 | }); |
| 498 | 548 | }); |
| 499 | 549 | |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import type { NativeApi, ProviderEvent, ProviderKind, ProviderSession } from "@t3tools/contracts"; |
| 2 | −import type { ChatMessage, SessionPhase } from "./types"; | |
| 2 | +import type { ChatMessage, SessionPhase, TurnDiffFileChange, TurnDiffSummary } from "./types"; | |
| 3 | 3 | import { createWsNativeApi } from "./wsNativeApi"; |
| 4 | 4 | |
| 5 | 5 | export const PROVIDER_OPTIONS: Array<{ |
| @@ -99,20 +99,7 @@ export type TimelineEntry = · 93 unmodified lines | ||
| 99 | 99 | entry: WorkLogEntry; |
| 100 | 100 | }; |
| 101 | 101 | |
| 102 | −export interface TurnDiffFileChange { | |
| 103 | − path: string; | |
| 104 | − kind?: string; | |
| 105 | − diff?: string; | |
| 106 | −} | |
| 107 | − | |
| 108 | −export interface TurnDiffSummary { | |
| 109 | − turnId: string; | |
| 110 | − completedAt: string; | |
| 111 | − status?: string; | |
| 112 | − files: TurnDiffFileChange[]; | |
| 113 | − unifiedDiff?: string; | |
| 114 | − assistantMessageId?: string; | |
| 115 | −} | |
| 102 | +export type { TurnDiffFileChange, TurnDiffSummary } from "./types"; | |
| 116 | 103 | |
| 117 | 104 | function normalizeDetail(value: string | undefined): string | undefined { |
| 118 | 105 | if (!value) return undefined; |
| @@ -120,10 +107,6 @@ function normalizeDetail(value: string | undefined): string | undefined { · 1 unmodified line | ||
| 120 | 107 | return trimmed.length > 0 ? trimmed : undefined; |
| 121 | 108 | } |
| 122 | 109 | |
| 123 | −function asArray(value: unknown): unknown[] | undefined { | |
| 124 | − return Array.isArray(value) ? value : undefined; | |
| 125 | −} | |
| 126 | − | |
| 127 | 110 | function approvalDetail(event: ProviderEvent): string | undefined { |
| 128 | 111 | const payload = asObject(event.payload); |
| 129 | 112 | const command = asString(payload?.command); |
| @@ -457,19 +440,11 @@ export function deriveWorkLogEntries( · 327 unmodified lines | ||
| 457 | 440 | return entries; |
| 458 | 441 | } |
| 459 | 442 | |
| 460 | −interface MutableTurnDiffFileChange { | |
| 461 | − path: string; | |
| 462 | − kind: string | undefined; | |
| 463 | − diff: string | undefined; | |
| 464 | −} | |
| 465 | − | |
| 466 | 443 | interface MutableTurnDiffSummary { |
| 467 | 444 | turnId: string; |
| 468 | 445 | completedAt: string | undefined; |
| 469 | 446 | status: string | undefined; |
| 470 | − unifiedDiff: string | undefined; | |
| 471 | 447 | assistantMessageId: string | undefined; |
| 472 | − filesByPath: Map<string, MutableTurnDiffFileChange>; | |
| 473 | 448 | } |
| 474 | 449 | |
| 475 | 450 | function parsePathFromDiff(diff: string): string | undefined { |
| @@ -485,7 +460,7 @@ function parsePathFromDiff(diff: string): string | undefined { · 9 unmodified lines | ||
| 485 | 460 | return direct[1]; |
| 486 | 461 | } |
| 487 | 462 | |
| 488 | −function splitUnifiedDiffByFile(diff: string): Map<string, string> { | |
| 463 | +export function splitUnifiedDiffByFile(diff: string): Map<string, string> { | |
| 489 | 464 | const normalized = diff.replace(/\r\n/g, "\n"); |
| 490 | 465 | const byPath = new Map<string, string>(); |
| 491 | 466 | const headerMatches = [...normalized.matchAll(/^diff --git a\/(.+) b\/(.+)$/gm)]; |
| @@ -512,38 +487,71 @@ function splitUnifiedDiffByFile(diff: string): Map<string, string> { · 20 unmodified lines | ||
| 512 | 487 | return byPath; |
| 513 | 488 | } |
| 514 | 489 | |
| 515 | −function parseFileChangeEntriesFromEvent(event: ProviderEvent): TurnDiffFileChange[] { | |
| 516 | − if (event.method !== "item/started" && event.method !== "item/completed") { | |
| 517 | − return []; | |
| 490 | +export function deriveTurnDiffFilesFromUnifiedDiff(diff: string): TurnDiffFileChange[] { | |
| 491 | + const fileDiffsByPath = splitUnifiedDiffByFile(diff); | |
| 492 | + return Array.from(fileDiffsByPath.entries()) | |
| 493 | + .map(([path, fileDiff]) => { | |
| 494 | + const stat = countDiffStat(fileDiff); | |
| 495 | + return { | |
| 496 | + path, | |
| 497 | + diff: fileDiff, | |
| 498 | + additions: stat.additions, | |
| 499 | + deletions: stat.deletions, | |
| 500 | + }; | |
| 501 | + }) | |
| 502 | + .toSorted((a, b) => a.path.localeCompare(b.path)); | |
| 503 | +} | |
| 504 | + | |
| 505 | +export function inferCheckpointTurnCountByTurnId( | |
| 506 | + summaries: TurnDiffSummary[], | |
| 507 | +): Record<string, number> { | |
| 508 | + if (summaries.length === 0) { | |
| 509 | + return {}; | |
| 518 | 510 | } |
| 519 | 511 | |
| 520 | − const payload = asObject(event.payload); | |
| 521 | − const item = asObject(payload?.item); | |
| 522 | − if (asString(item?.type) !== "fileChange") { | |
| 523 | − return []; | |
| 524 | − } | |
| 525 | − | |
| 526 | − const changes = asArray(item?.changes); | |
| 527 | − if (!changes || changes.length === 0) { | |
| 528 | − return []; | |
| 529 | − } | |
| 530 | − | |
| 531 | − const files: TurnDiffFileChange[] = []; | |
| 532 | − for (const rawChange of changes) { | |
| 533 | − const change = asObject(rawChange); | |
| 534 | − if (!change) continue; | |
| 535 | − const diff = normalizeDetail(asString(change.diff)); | |
| 536 | − const path = normalizeDetail(asString(change.path)) ?? (diff ? parsePathFromDiff(diff) : undefined); | |
| 537 | − if (!path) continue; | |
| 538 | − const kind = normalizeDetail(asString(change.kind)); | |
| 539 | − files.push({ | |
| 540 | − path, | |
| 541 | − ...(kind ? { kind } : {}), | |
| 542 | − ...(diff ? { diff } : {}), | |
| 543 | − }); | |
| 512 | + const sorted = [...summaries].toSorted((a, b) => { | |
| 513 | + const aTime = Date.parse(a.completedAt); | |
| 514 | + const bTime = Date.parse(b.completedAt); | |
| 515 | + if (Number.isNaN(aTime) || Number.isNaN(bTime)) { | |
| 516 | + return a.completedAt.localeCompare(b.completedAt); | |
| 517 | + } | |
| 518 | + return aTime - bTime; | |
| 519 | + }); | |
| 520 | + | |
| 521 | + const next: Record<string, number> = {}; | |
| 522 | + let fallbackTurnCount = 1; | |
| 523 | + for (const summary of sorted) { | |
| 524 | + if (typeof summary.checkpointTurnCount === "number") { | |
| 525 | + next[summary.turnId] = summary.checkpointTurnCount; | |
| 526 | + fallbackTurnCount = Math.max(fallbackTurnCount, summary.checkpointTurnCount + 1); | |
| 527 | + continue; | |
| 528 | + } | |
| 529 | + next[summary.turnId] = fallbackTurnCount; | |
| 530 | + fallbackTurnCount += 1; | |
| 531 | + } | |
| 532 | + | |
| 533 | + return next; | |
| 534 | +} | |
| 535 | + | |
| 536 | +export function countDiffStat(patch: string | undefined): { additions: number; deletions: number } { | |
| 537 | + if (!patch) { | |
| 538 | + return { additions: 0, deletions: 0 }; | |
| 539 | + } | |
| 540 | + | |
| 541 | + let additions = 0; | |
| 542 | + let deletions = 0; | |
| 543 | + for (const line of patch.replace(/\r\n/g, "\n").split("\n")) { | |
| 544 | + if (line.startsWith("+++ ") || line.startsWith("--- ")) continue; | |
| 545 | + if (line.startsWith("+")) { | |
| 546 | + additions += 1; | |
| 547 | + continue; | |
| 548 | + } | |
| 549 | + if (line.startsWith("-")) { | |
| 550 | + deletions += 1; | |
| 551 | + } | |
| 544 | 552 | } |
| 545 | 553 | |
| 546 | − return files; | |
| 554 | + return { additions, deletions }; | |
| 547 | 555 | } |
| 548 | 556 | |
| 549 | 557 | export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummary[] { |
| @@ -557,9 +565,7 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar · 7 unmodified lines | ||
| 557 | 565 | turnId, |
| 558 | 566 | completedAt: undefined, |
| 559 | 567 | status: undefined, |
| 560 | − unifiedDiff: undefined, | |
| 561 | 568 | assistantMessageId: undefined, |
| 562 | − filesByPath: new Map(), | |
| 563 | 569 | }; |
| 564 | 570 | byTurnId.set(turnId, next); |
| 565 | 571 | return next; |
| @@ -577,12 +583,6 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar · 11 unmodified lines | ||
| 577 | 583 | continue; |
| 578 | 584 | } |
| 579 | 585 | |
| 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 | 586 | if (event.method === "item/completed") { |
| 587 | 587 | const item = asObject(asObject(event.payload)?.item); |
| 588 | 588 | if (asString(item?.type) === "agentMessage") { |
| @@ -592,66 +592,17 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar · 3 unmodified lines | ||
| 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 | } |
| 613 | 596 | |
| 614 | 597 | const summaries: TurnDiffSummary[] = []; |
| 615 | 598 | for (const summary of byTurnId.values()) { |
| 616 | 599 | if (!summary.completedAt) continue; |
| 617 | 600 | |
| 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 | 601 | summaries.push({ |
| 650 | 602 | turnId: summary.turnId, |
| 651 | 603 | completedAt: summary.completedAt, |
| 652 | 604 | ...(summary.status ? { status: summary.status } : {}), |
| 653 | − files, | |
| 654 | − ...(summary.unifiedDiff ? { unifiedDiff: summary.unifiedDiff } : {}), | |
| 605 | + files: [], | |
| 655 | 606 | ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}), |
| 656 | 607 | }); |
| 657 | 608 | } |
| @@ -57,6 +57,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 56 unmodified lines | ||
| 57 | 57 | session: makeSession(), |
| 58 | 58 | messages: [], |
| 59 | 59 | events: [], |
| 60 | + turnDiffSummaries: [], | |
| 60 | 61 | error: null, |
| 61 | 62 | createdAt: "2026-02-09T00:00:00.000Z", |
| 62 | 63 | branch: null, |
| @@ -517,6 +518,57 @@ describe("store reducer thread continuity", () => { · 454 unmodified lines | ||
| 517 | 518 | expect(next.threads[0]?.session?.threadId).toBe("thr_new"); |
| 518 | 519 | }); |
| 519 | 520 | |
| 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 | 572 | it("reconciles project ids by cwd when syncing backend projects", () => { |
| 521 | 573 | const state: AppState = { |
| 522 | 574 | projects: [ |
| @@ -781,6 +833,20 @@ describe("store reducer thread continuity", () => { · 258 unmodified lines | ||
| 781 | 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 | 850 | error: "temporary failure", |
| 785 | 851 | latestTurnId: "turn-live", |
| 786 | 852 | latestTurnStartedAt: "2026-02-08T10:00:03.000Z", |
| @@ -792,6 +858,7 @@ describe("store reducer thread continuity", () => { · 5 unmodified lines | ||
| 792 | 858 | threadId: "thread-local-1", |
| 793 | 859 | sessionId: "sess-1", |
| 794 | 860 | threadRuntimeId: "thr_after", |
| 861 | + turnCount: 1, | |
| 795 | 862 | messageCount: 2, |
| 796 | 863 | }); |
| 797 | 864 | |
| @@ -803,5 +870,77 @@ describe("store reducer thread continuity", () => { · 5 unmodified lines | ||
| 803 | 870 | expect(next.threads[0]?.session?.activeTurnId).toBeUndefined(); |
| 804 | 871 | expect(next.threads[0]?.session?.threadId).toBe("thr_after"); |
| 805 | 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 | }); |
| @@ -11,7 +11,15 @@ import { · 10 unmodified lines | ||
| 11 | 11 | import { type ProviderEvent, type ProviderSession, type TerminalEvent, normalizeProjectScripts } from "@t3tools/contracts"; |
| 12 | 12 | import { resolveModelSlug } from "./model-logic"; |
| 13 | 13 | import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; |
| 14 | −import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic"; | |
| 14 | +import { | |
| 15 | + applyEventToMessages, | |
| 16 | + asObject, | |
| 17 | + asString, | |
| 18 | + deriveTurnDiffFilesFromUnifiedDiff, | |
| 19 | + deriveTurnDiffSummaries, | |
| 20 | + inferCheckpointTurnCountByTurnId, | |
| 21 | + evolveSession, | |
| 22 | +} from "./session-logic"; | |
| 15 | 23 | import { |
| 16 | 24 | type ChatAttachment, |
| 17 | 25 | DEFAULT_THREAD_TERMINAL_ID, |
| @@ -77,8 +85,19 @@ type Action = · 59 unmodified lines | ||
| 77 | 85 | threadId: string; |
| 78 | 86 | sessionId: string; |
| 79 | 87 | threadRuntimeId: string; |
| 88 | + turnCount: number; | |
| 80 | 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 | 102 | type: "SET_THREAD_BRANCH"; |
| 84 | 103 | threadId: string; |
| @@ -179,6 +198,77 @@ function updateThread( · 94 unmodified lines | ||
| 179 | 198 | return threads.map((t) => (t.id === threadId ? updater(t) : t)); |
| 180 | 199 | } |
| 181 | 200 | |
| 201 | +function mergeTurnDiffSummaries( | |
| 202 | + existing: Thread["turnDiffSummaries"], | |
| 203 | + next: Thread["turnDiffSummaries"], | |
| 204 | +): Thread["turnDiffSummaries"] { | |
| 205 | + if (existing.length === 0) return next; | |
| 206 | + if (next.length === 0) return existing; | |
| 207 | + | |
| 208 | + const existingByTurnId = new Map(existing.map((summary) => [summary.turnId, summary] as const)); | |
| 209 | + const merged = next.map((summary) => { | |
| 210 | + const previous = existingByTurnId.get(summary.turnId); | |
| 211 | + if (!previous) { | |
| 212 | + return summary; | |
| 213 | + } | |
| 214 | + | |
| 215 | + const prefersCheckpointDiff = previous.checkpointDiffLoaded || summary.checkpointDiffLoaded; | |
| 216 | + const files = | |
| 217 | + prefersCheckpointDiff || | |
| 218 | + (summary.files.length === 0 && previous.files.length > 0 && summary.unifiedDiff === undefined) | |
| 219 | + ? previous.files | |
| 220 | + : summary.files; | |
| 221 | + const unifiedDiff = | |
| 222 | + prefersCheckpointDiff || summary.unifiedDiff === undefined | |
| 223 | + ? (previous.unifiedDiff ?? summary.unifiedDiff) | |
| 224 | + : summary.unifiedDiff; | |
| 225 | + | |
| 226 | + return { | |
| 227 | + ...summary, | |
| 228 | + files, | |
| 229 | + ...(unifiedDiff !== undefined ? { unifiedDiff } : {}), | |
| 230 | + ...(summary.assistantMessageId | |
| 231 | + ? {} | |
| 232 | + : previous.assistantMessageId | |
| 233 | + ? { assistantMessageId: previous.assistantMessageId } | |
| 234 | + : {}), | |
| 235 | + ...(typeof summary.checkpointTurnCount === "number" | |
| 236 | + ? {} | |
| 237 | + : typeof previous.checkpointTurnCount === "number" | |
| 238 | + ? { checkpointTurnCount: previous.checkpointTurnCount } | |
| 239 | + : {}), | |
| 240 | + ...(summary.checkpointDiffLoaded || previous.checkpointDiffLoaded | |
| 241 | + ? { checkpointDiffLoaded: true } | |
| 242 | + : {}), | |
| 243 | + }; | |
| 244 | + }); | |
| 245 | + | |
| 246 | + const mergedTurnIds = new Set(merged.map((summary) => summary.turnId)); | |
| 247 | + for (const summary of existing) { | |
| 248 | + if (!mergedTurnIds.has(summary.turnId)) { | |
| 249 | + merged.push(summary); | |
| 250 | + } | |
| 251 | + } | |
| 252 | + | |
| 253 | + const sorted = merged.toSorted((a, b) => { | |
| 254 | + const aTime = Date.parse(a.completedAt); | |
| 255 | + const bTime = Date.parse(b.completedAt); | |
| 256 | + if (Number.isNaN(aTime) || Number.isNaN(bTime)) { | |
| 257 | + return b.completedAt.localeCompare(a.completedAt); | |
| 258 | + } | |
| 259 | + return bTime - aTime; | |
| 260 | + }); | |
| 261 | + | |
| 262 | + const inferredTurnCountByTurnId = inferCheckpointTurnCountByTurnId(sorted); | |
| 263 | + return sorted.map((summary) => | |
| 264 | + typeof summary.checkpointTurnCount === "number" | |
| 265 | + ? summary | |
| 266 | + : Object.assign({}, summary, { | |
| 267 | + checkpointTurnCount: inferredTurnCountByTurnId[summary.turnId], | |
| 268 | + }), | |
| 269 | + ); | |
| 270 | +} | |
| 271 | + | |
| 182 | 272 | function normalizeTerminalIds(terminalIds: string[]): string[] { |
| 183 | 273 | const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); |
| 184 | 274 | const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT); |
| @@ -554,6 +644,7 @@ export function reducer(state: AppState, action: Action): AppState { · 369 unmodified lines | ||
| 554 | 644 | ...action.thread, |
| 555 | 645 | model: resolveModelSlug(action.thread.model), |
| 556 | 646 | lastVisitedAt: action.thread.lastVisitedAt ?? action.thread.createdAt, |
| 647 | + turnDiffSummaries: action.thread.turnDiffSummaries ?? [], | |
| 557 | 648 | }); |
| 558 | 649 | return { |
| 559 | 650 | ...state, |
| @@ -800,27 +891,32 @@ export function reducer(state: AppState, action: Action): AppState { · 240 unmodified lines | ||
| 800 | 891 | |
| 801 | 892 | return { |
| 802 | 893 | ...state, |
| 803 | − threads: updateThread(state.threads, target.id, (t) => ({ | |
| 804 | − ...t, | |
| 805 | − ...(() => { | |
| 806 | − const eventThreadId = getEventThreadId(event); | |
| 807 | − const shouldRebindIdentity = | |
| 808 | − event.method === "thread/started" && t.session?.status === "connecting"; | |
| 809 | − return { | |
| 810 | − codexThreadId: shouldRebindIdentity | |
| 811 | − ? (eventThreadId ?? t.codexThreadId) | |
| 812 | − : (t.codexThreadId ?? eventThreadId ?? null), | |
| 813 | − error: event.kind === "error" && event.message ? event.message : t.error, | |
| 814 | − }; | |
| 815 | − })(), | |
| 816 | − session: t.session ? evolveSession(t.session, event) : t.session, | |
| 817 | − messages: applyEventToMessages(t.messages, event, activeAssistantItemRef), | |
| 818 | − events: [event, ...t.events], | |
| 819 | − ...updateTurnFields(t, event), | |
| 820 | − ...(event.method === "turn/completed" && t.id === state.activeThreadId | |
| 821 | − ? { lastVisitedAt: event.createdAt } | |
| 822 | − : {}), | |
| 823 | − })), | |
| 894 | + threads: updateThread(state.threads, target.id, (t) => { | |
| 895 | + const nextEvents = [event, ...t.events]; | |
| 896 | + const derivedTurnDiffSummaries = deriveTurnDiffSummaries(nextEvents); | |
| 897 | + const turnDiffSummaries = mergeTurnDiffSummaries( | |
| 898 | + t.turnDiffSummaries, | |
| 899 | + derivedTurnDiffSummaries, | |
| 900 | + ); | |
| 901 | + const eventThreadId = getEventThreadId(event); | |
| 902 | + const shouldRebindIdentity = | |
| 903 | + event.method === "thread/started" && t.session?.status === "connecting"; | |
| 904 | + return { | |
| 905 | + ...t, | |
| 906 | + codexThreadId: shouldRebindIdentity | |
| 907 | + ? (eventThreadId ?? t.codexThreadId) | |
| 908 | + : (t.codexThreadId ?? eventThreadId ?? null), | |
| 909 | + error: event.kind === "error" && event.message ? event.message : t.error, | |
| 910 | + session: t.session ? evolveSession(t.session, event) : t.session, | |
| 911 | + messages: applyEventToMessages(t.messages, event, activeAssistantItemRef), | |
| 912 | + events: nextEvents, | |
| 913 | + turnDiffSummaries, | |
| 914 | + ...updateTurnFields(t, event), | |
| 915 | + ...(event.method === "turn/completed" && t.id === state.activeThreadId | |
| 916 | + ? { lastVisitedAt: event.createdAt } | |
| 917 | + : {}), | |
| 918 | + }; | |
| 919 | + }), | |
| 824 | 920 | }; |
| 825 | 921 | } |
| 826 | 922 | |
| @@ -893,6 +989,7 @@ export function reducer(state: AppState, action: Action): AppState { · 66 unmodified lines | ||
| 893 | 989 | ...state, |
| 894 | 990 | threads: updateThread(state.threads, action.threadId, (t) => { |
| 895 | 991 | const nextMessageCount = Math.max(0, Math.floor(action.messageCount)); |
| 992 | + const nextTurnCount = Math.max(0, Math.floor(action.turnCount)); | |
| 896 | 993 | const now = new Date().toISOString(); |
| 897 | 994 | return { |
| 898 | 995 | ...t, |
| @@ -910,6 +1007,11 @@ export function reducer(state: AppState, action: Action): AppState { · 11 unmodified lines | ||
| 910 | 1007 | : t.session, |
| 911 | 1008 | messages: t.messages.slice(0, nextMessageCount), |
| 912 | 1009 | events: [], |
| 1010 | + turnDiffSummaries: t.turnDiffSummaries.filter( | |
| 1011 | + (summary) => | |
| 1012 | + typeof summary.checkpointTurnCount === "number" && | |
| 1013 | + summary.checkpointTurnCount <= nextTurnCount, | |
| 1014 | + ), | |
| 913 | 1015 | error: null, |
| 914 | 1016 | latestTurnId: undefined, |
| 915 | 1017 | latestTurnStartedAt: undefined, |
| @@ -920,6 +1022,83 @@ export function reducer(state: AppState, action: Action): AppState { · 4 unmodified lines | ||
| 920 | 1022 | }), |
| 921 | 1023 | }; |
| 922 | 1024 | |
| 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 | 1102 | case "SET_THREAD_BRANCH": { |
| 924 | 1103 | return { |
| 925 | 1104 | ...state, |
| @@ -37,6 +37,25 @@ export interface ChatMessage { · 36 unmodified lines | ||
| 37 | 37 | streaming: boolean; |
| 38 | 38 | } |
| 39 | 39 | |
| 40 | +export interface TurnDiffFileChange { | |
| 41 | + path: string; | |
| 42 | + kind?: string | undefined; | |
| 43 | + diff?: string | undefined; | |
| 44 | + additions?: number | undefined; | |
| 45 | + deletions?: number | undefined; | |
| 46 | +} | |
| 47 | + | |
| 48 | +export interface TurnDiffSummary { | |
| 49 | + turnId: string; | |
| 50 | + completedAt: string; | |
| 51 | + status?: string | undefined; | |
| 52 | + files: TurnDiffFileChange[]; | |
| 53 | + unifiedDiff?: string | undefined; | |
| 54 | + assistantMessageId?: string | undefined; | |
| 55 | + checkpointTurnCount?: number | undefined; | |
| 56 | + checkpointDiffLoaded?: boolean | undefined; | |
| 57 | +} | |
| 58 | + | |
| 40 | 59 | export interface Project { |
| 41 | 60 | id: string; |
| 42 | 61 | name: string; |
| @@ -71,4 +90,5 @@ export interface Thread { · 28 unmodified lines | ||
| 71 | 90 | lastVisitedAt?: string | undefined; |
| 72 | 91 | branch: string | null; |
| 73 | 92 | worktreePath: string | null; |
| 93 | + turnDiffSummaries: TurnDiffSummary[]; | |
| 74 | 94 | } |
| @@ -25,6 +25,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 24 unmodified lines | ||
| 25 | 25 | session: null, |
| 26 | 26 | messages: [], |
| 27 | 27 | events: [], |
| 28 | + turnDiffSummaries: [], | |
| 28 | 29 | error: null, |
| 29 | 30 | createdAt: "2026-02-13T00:00:00.000Z", |
| 30 | 31 | branch: null, |
| @@ -92,6 +92,7 @@ export function createWsNativeApi(): NativeApi { · 91 unmodified lines | ||
| 92 | 92 | stopSession: (input) => transport.request(WS_METHODS.providersStopSession, input), |
| 93 | 93 | listSessions: () => transport.request(WS_METHODS.providersListSessions), |
| 94 | 94 | listCheckpoints: (input) => transport.request(WS_METHODS.providersListCheckpoints, input), |
| 95 | + getCheckpointDiff: (input) => transport.request(WS_METHODS.providersGetCheckpointDiff, input), | |
| 95 | 96 | revertToCheckpoint: (input) => |
| 96 | 97 | transport.request(WS_METHODS.providersRevertToCheckpoint, input), |
| 97 | 98 | onEvent: (callback) => |
| @@ -37,6 +37,9 @@ export class WsTransport { · 36 unmodified lines | ||
| 37 | 37 | } |
| 38 | 38 | |
| 39 | 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 | 43 | const id = String(this.nextId++); |
| 41 | 44 | const message: WsRequest = { id, method, ...(params !== undefined ? { params } : {}) }; |
| 42 | 45 | |
| @@ -12,7 +12,7 @@ · 11 unmodified lines | ||
| 12 | 12 | "exports": { |
| 13 | 13 | ".": { |
| 14 | 14 | "types": "./src/index.ts", |
| 15 | − "import": "./dist/index.mjs", | |
| 15 | + "import": "./src/index.ts", | |
| 16 | 16 | "require": "./dist/index.cjs" |
| 17 | 17 | } |
| 18 | 18 | }, |
| @@ -17,6 +17,8 @@ import type { · 16 unmodified lines | ||
| 17 | 17 | } from "./git"; |
| 18 | 18 | import type { |
| 19 | 19 | ProviderEvent, |
| 20 | + ProviderGetCheckpointDiffInput, | |
| 21 | + ProviderGetCheckpointDiffResult, | |
| 20 | 22 | ProviderInterruptTurnInput, |
| 21 | 23 | ProviderListCheckpointsInput, |
| 22 | 24 | ProviderListCheckpointsResult, |
| @@ -94,6 +96,9 @@ export interface NativeApi { · 71 unmodified lines | ||
| 94 | 96 | stopSession: (input: ProviderStopSessionInput) => Promise<void>; |
| 95 | 97 | listSessions: () => Promise<ProviderSession[]>; |
| 96 | 98 | listCheckpoints: (input: ProviderListCheckpointsInput) => Promise<ProviderListCheckpointsResult>; |
| 99 | + getCheckpointDiff: ( | |
| 100 | + input: ProviderGetCheckpointDiffInput, | |
| 101 | + ) => Promise<ProviderGetCheckpointDiffResult>; | |
| 97 | 102 | revertToCheckpoint: ( |
| 98 | 103 | input: ProviderRevertToCheckpointInput, |
| 99 | 104 | ) => Promise<ProviderRevertToCheckpointResult>; |
| @@ -4,6 +4,8 @@ import { · 3 unmodified lines | ||
| 4 | 4 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 5 | 5 | providerCheckpointSchema, |
| 6 | 6 | providerEventSchema, |
| 7 | + providerGetCheckpointDiffInputSchema, | |
| 8 | + providerGetCheckpointDiffResultSchema, | |
| 7 | 9 | providerListCheckpointsInputSchema, |
| 8 | 10 | providerListCheckpointsResultSchema, |
| 9 | 11 | providerRevertToCheckpointInputSchema, |
| @@ -229,4 +231,31 @@ describe("provider checkpoint schemas", () => { · 219 unmodified lines | ||
| 229 | 231 | }), |
| 230 | 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 | }); |
| @@ -142,6 +142,29 @@ export const providerRevertToCheckpointResultSchema = z.object({ · 141 unmodified lines | ||
| 142 | 142 | checkpoints: z.array(providerCheckpointSchema), |
| 143 | 143 | }); |
| 144 | 144 | |
| 145 | +export const providerGetCheckpointDiffInputSchema = z | |
| 146 | + .object({ | |
| 147 | + sessionId: z.string().min(1), | |
| 148 | + fromTurnCount: z.number().int().min(0), | |
| 149 | + toTurnCount: z.number().int().min(0), | |
| 150 | + }) | |
| 151 | + .superRefine((value, ctx) => { | |
| 152 | + if (value.fromTurnCount > value.toTurnCount) { | |
| 153 | + ctx.addIssue({ | |
| 154 | + code: z.ZodIssueCode.custom, | |
| 155 | + message: "fromTurnCount must be less than or equal to toTurnCount", | |
| 156 | + path: ["fromTurnCount"], | |
| 157 | + }); | |
| 158 | + } | |
| 159 | + }); | |
| 160 | + | |
| 161 | +export const providerGetCheckpointDiffResultSchema = z.object({ | |
| 162 | + threadId: z.string().min(1), | |
| 163 | + fromTurnCount: z.number().int().min(0), | |
| 164 | + toTurnCount: z.number().int().min(0), | |
| 165 | + diff: z.string(), | |
| 166 | +}); | |
| 167 | + | |
| 145 | 168 | export const providerRespondToRequestInputSchema = z.object({ |
| 146 | 169 | sessionId: z.string().min(1), |
| 147 | 170 | requestId: z.string().min(1), |
| @@ -189,6 +212,8 @@ export type ProviderRevertToCheckpointInput = z.input<typeof providerRevertToChe · 41 unmodified lines | ||
| 189 | 212 | export type ProviderRevertToCheckpointResult = z.infer< |
| 190 | 213 | typeof providerRevertToCheckpointResultSchema |
| 191 | 214 | >; |
| 215 | +export type ProviderGetCheckpointDiffInput = z.input<typeof providerGetCheckpointDiffInputSchema>; | |
| 216 | +export type ProviderGetCheckpointDiffResult = z.infer<typeof providerGetCheckpointDiffResultSchema>; | |
| 192 | 217 | export type ProviderRespondToRequestInput = z.input<typeof providerRespondToRequestInputSchema>; |
| 193 | 218 | export type ProviderEventKind = z.infer<typeof providerEventKindSchema>; |
| 194 | 219 | export type ProviderEvent = z.infer<typeof providerEventSchema>; |
| @@ -11,6 +11,7 @@ export const WS_METHODS = { · 10 unmodified lines | ||
| 11 | 11 | providersStopSession: "providers.stopSession", |
| 12 | 12 | providersListSessions: "providers.listSessions", |
| 13 | 13 | providersListCheckpoints: "providers.listCheckpoints", |
| 14 | + providersGetCheckpointDiff: "providers.getCheckpointDiff", | |
| 14 | 15 | providersRevertToCheckpoint: "providers.revertToCheckpoint", |
| 15 | 16 | |
| 16 | 17 | // Project registry methods |