| author | |
| committer | |
| log | cf8a2f03fc1c46b70faa565b2e00625cb8b58ebd |
| tree | ef6db473f17d622037f1779c38b631e5d7d19edd |
| parent | 582615971ef76e55f994e2fc0ccd9171277551c2 |
- 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 screens27 files changed, 2267 insertions(+), 480 deletions(-)
apps/server/src/codexAppServerManager.ts+4-3| ... | ... | @@ -148,19 +148,20 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 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 |
| 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 | }; |
apps/server/src/filesystemCheckpointStore.test.ts+75| ... | ... | @@ -93,6 +93,32 @@ describe("FilesystemCheckpointStore", () => { |
| 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", () => { |
| 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(); |
apps/server/src/filesystemCheckpointStore.ts+79-1| ... | ... | @@ -91,10 +91,30 @@ export class FilesystemCheckpointStore { |
| 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 { |
| 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) { |
apps/server/src/providerManager.test.ts+337| ... | ... | @@ -76,6 +76,14 @@ describe("ProviderManager", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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 { |
apps/server/src/providerManager.ts+105-5| ... | ... | @@ -6,6 +6,8 @@ import { randomUUID } from "node:crypto"; |
| 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 { |
| 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> { |
| 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> { |
| 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> { |
| 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> { |
| 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> { |
| 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> { |
| 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> { |
| 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> { |
| 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 |
apps/server/src/wsServer.test.ts+8| ... | ... | @@ -668,6 +668,14 @@ describe("WebSocket Server", () => { |
| 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 () => { |
apps/server/src/wsServer.ts+3| ... | ... | @@ -313,6 +313,9 @@ export function createServer(options: ServerOptions) { |
| 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 |
apps/web/src/App.tsx+34-3| ... | ... | @@ -9,10 +9,13 @@ import { StoreProvider, useStore } from "./store"; |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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 | } |
apps/web/src/components/ChatView.tsx+427-238| ... | ... | @@ -8,7 +8,6 @@ import { |
| 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 { |
| 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 { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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 { |
| 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({ |
| 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({ |
| 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 { |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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 | })()} |
apps/web/src/components/DiffPanel.tsx+475-90| ... | ... | @@ -1,62 +1,473 @@ |
| 1 | import { PatchDiff } from "@pierre/diffs/react"; | |
| 2 | import { XIcon } from "lucide-react"; | |
| 3 | import { useMemo } from "react"; | |
| 1 | import { parsePatchFiles } from "@pierre/diffs"; | |
| 2 | import { FileDiff, type FileDiffMetadata } from "@pierre/diffs/react"; | |
| 3 | import { Columns2Icon, Rows3Icon, XIcon } from "lucide-react"; | |
| 4 | import { useEffect, useMemo, useRef, useState } from "react"; | |
| 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() { |
| 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 | )} |
apps/web/src/components/Sidebar.tsx+1| ... | ... | @@ -170,6 +170,7 @@ export default function Sidebar() { |
| 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, |
apps/web/src/hooks/useMediaQuery.ts created+27| ... | ... | @@ -0,0 +1,27 @@ |
| 1 | import { useEffect, useState } from "react"; | |
| 2 | ||
| 3 | function getMediaQueryMatch(query: string): boolean { | |
| 4 | if (typeof window === "undefined") { | |
| 5 | return false; | |
| 6 | } | |
| 7 | return window.matchMedia(query).matches; | |
| 8 | } | |
| 9 | ||
| 10 | export function useMediaQuery(query: string): boolean { | |
| 11 | const [matches, setMatches] = useState(() => getMediaQueryMatch(query)); | |
| 12 | ||
| 13 | useEffect(() => { | |
| 14 | const mediaQueryList = window.matchMedia(query); | |
| 15 | const handleChange = () => { | |
| 16 | setMatches(mediaQueryList.matches); | |
| 17 | }; | |
| 18 | ||
| 19 | setMatches(mediaQueryList.matches); | |
| 20 | mediaQueryList.addEventListener("change", handleChange); | |
| 21 | return () => { | |
| 22 | mediaQueryList.removeEventListener("change", handleChange); | |
| 23 | }; | |
| 24 | }, [query]); | |
| 25 | ||
| 26 | return matches; | |
| 27 | } |
apps/web/src/persistenceSchema.test.ts+98| ... | ... | @@ -263,6 +263,68 @@ describe("hydratePersistedState", () => { |
| 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", () => { |
| 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", () => { |
| 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(); |
apps/web/src/persistenceSchema.ts+50| ... | ... | @@ -47,6 +47,23 @@ const persistedTerminalGroupSchema = z.object({ |
| 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({ |
| 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( |
| 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( |
| 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, |
apps/web/src/session-logic.test.ts+53-3| ... | ... | @@ -5,6 +5,7 @@ import { |
| 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", () => { |
| 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", () => { |
| 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 |
apps/web/src/session-logic.ts+65-114| ... | ... | @@ -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 = |
| 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 { |
| 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( |
| 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 { |
| 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> { |
| 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 |
| 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 |
| 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 |
| 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 | } |
apps/web/src/store.test.ts+139| ... | ... | @@ -57,6 +57,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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 | }); |
apps/web/src/store.ts+201-22| ... | ... | @@ -11,7 +11,15 @@ import { |
| 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 = |
| 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( |
| 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 { |
| 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 { |
| 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 { |
| 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 { |
| 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 { |
| 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, |
apps/web/src/types.ts+20| ... | ... | @@ -37,6 +37,25 @@ export interface ChatMessage { |
| 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 { |
| 71 | 90 | lastVisitedAt?: string | undefined; |
| 72 | 91 | branch: string | null; |
| 73 | 92 | worktreePath: string | null; |
| 93 | turnDiffSummaries: TurnDiffSummary[]; | |
| 74 | 94 | } |
apps/web/src/worktreeCleanup.test.ts+1| ... | ... | @@ -25,6 +25,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 25 | 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, |
apps/web/src/wsNativeApi.ts+1| ... | ... | @@ -92,6 +92,7 @@ export function createWsNativeApi(): NativeApi { |
| 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) => |
apps/web/src/wsTransport.ts+3| ... | ... | @@ -37,6 +37,9 @@ export class WsTransport { |
| 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 |
packages/contracts/package.json+1-1| ... | ... | @@ -12,7 +12,7 @@ |
| 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 | }, |
packages/contracts/src/ipc.ts+5| ... | ... | @@ -17,6 +17,8 @@ import type { |
| 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 { |
| 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>; |
packages/contracts/src/provider.test.ts+29| ... | ... | @@ -4,6 +4,8 @@ import { |
| 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", () => { |
| 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 | }); |
packages/contracts/src/provider.ts+25| ... | ... | @@ -142,6 +142,29 @@ export const providerRevertToCheckpointResultSchema = z.object({ |
| 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 |
| 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>; |
packages/contracts/src/ws.ts+1| ... | ... | @@ -11,6 +11,7 @@ export const WS_METHODS = { |
| 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 |