From cf8a2f03fc1c46b70faa565b2e00625cb8b58ebd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 17 Feb 2026 23:54:49 -0800 Subject: [PATCH] Add checkpoint diff API and hydrate turn diffs in UI - add server support to diff filesystem checkpoints, including root/HEAD fallbacks and lazy checkpoint initialization - wire new `providers.getCheckpointDiff` WebSocket route through provider manager and contracts - update web app to load per-turn diffs from checkpoints and improve diff panel behavior on narrow screens --- apps/server/src/codexAppServerManager.ts | 7 +- .../src/filesystemCheckpointStore.test.ts | 75 ++ apps/server/src/filesystemCheckpointStore.ts | 80 ++- apps/server/src/providerManager.test.ts | 337 +++++++++ apps/server/src/providerManager.ts | 110 ++- apps/server/src/wsServer.test.ts | 8 + apps/server/src/wsServer.ts | 3 + apps/web/src/App.tsx | 37 +- apps/web/src/components/ChatView.tsx | 665 +++++++++++------- apps/web/src/components/DiffPanel.tsx | 565 ++++++++++++--- apps/web/src/components/Sidebar.tsx | 1 + apps/web/src/hooks/useMediaQuery.ts | 27 + apps/web/src/persistenceSchema.test.ts | 98 +++ apps/web/src/persistenceSchema.ts | 50 ++ apps/web/src/session-logic.test.ts | 56 +- apps/web/src/session-logic.ts | 171 ++--- apps/web/src/store.test.ts | 139 ++++ apps/web/src/store.ts | 223 +++++- apps/web/src/types.ts | 20 + apps/web/src/worktreeCleanup.test.ts | 1 + apps/web/src/wsNativeApi.ts | 1 + apps/web/src/wsTransport.ts | 3 + packages/contracts/package.json | 2 +- packages/contracts/src/ipc.ts | 5 + packages/contracts/src/provider.test.ts | 29 + packages/contracts/src/provider.ts | 25 + packages/contracts/src/ws.ts | 1 + 27 files changed, 2263 insertions(+), 476 deletions(-) create mode 100644 apps/web/src/hooks/useMediaQuery.ts diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 01722a69667c6df8e684ddfbd72a39b59557597a..73af81bbe8e441b649c60eba5e7c19496269eee6 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -148,19 +148,20 @@ export class CodexAppServerManager extends EventEmitter { const sessionId = randomUUID(); const now = new Date().toISOString(); + const resolvedCwd = input.cwd ?? process.cwd(); const session: ProviderSession = { sessionId, provider: "codex", status: "connecting", model: normalizeCodexModelSlug(input.model), - cwd: input.cwd, + cwd: resolvedCwd, createdAt: now, updatedAt: now, }; const child = spawn("codex", ["app-server"], { - cwd: input.cwd, + cwd: resolvedCwd, env: process.env, stdio: ["pipe", "pipe", "pipe"], }); @@ -198,7 +199,7 @@ export class CodexAppServerManager extends EventEmitter { } }); + it("falls back to HEAD when restoring turn 0 and root checkpoint ref is missing", async () => { + const cwd = await createRepo(); + const store = new FilesystemCheckpointStore(); + const threadId = "thr_2b"; + + try { + await writeFile(path.join(cwd, "README.md"), "v2\n"); + await store.captureCheckpoint({ cwd, threadId, turnCount: 1 }); + + await writeFile(path.join(cwd, "README.md"), "v3\n"); + await writeFile(path.join(cwd, "scratch.txt"), "scratch\n"); + + const restored = await store.restoreCheckpoint({ + cwd, + threadId, + turnCount: 0, + }); + + expect(restored).toBe(true); + expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("v1\n"); + expect(exists(path.join(cwd, "scratch.txt"))).toBe(false); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + it("prunes refs newer than the retained turn", async () => { const cwd = await createRepo(); const store = new FilesystemCheckpointStore(); @@ -119,6 +145,55 @@ describe("FilesystemCheckpointStore", () => { } }); + it("returns a unified patch between checkpoint turns", async () => { + const cwd = await createRepo(); + const store = new FilesystemCheckpointStore(); + const threadId = "thr_4"; + + try { + await store.captureCheckpoint({ cwd, threadId, turnCount: 0 }); + await writeFile(path.join(cwd, "README.md"), "v2\n"); + await writeFile(path.join(cwd, "new.txt"), "new file\n"); + await store.captureCheckpoint({ cwd, threadId, turnCount: 1 }); + + const diff = await store.diffCheckpoints({ + cwd, + threadId, + fromTurnCount: 0, + toTurnCount: 1, + }); + + expect(diff).toContain("diff --git a/README.md b/README.md"); + expect(diff).toContain("diff --git a/new.txt b/new.txt"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + it("falls back to HEAD as diff base when turn 0 checkpoint is missing", async () => { + const cwd = await createRepo(); + const store = new FilesystemCheckpointStore(); + const threadId = "thr_5"; + + try { + await writeFile(path.join(cwd, "README.md"), "v2\n"); + await writeFile(path.join(cwd, "new.txt"), "new file\n"); + await store.captureCheckpoint({ cwd, threadId, turnCount: 1 }); + + const diff = await store.diffCheckpoints({ + cwd, + threadId, + fromTurnCount: 0, + toTurnCount: 1, + }); + + expect(diff).toContain("diff --git a/README.md b/README.md"); + expect(diff).toContain("diff --git a/new.txt b/new.txt"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + it("detects non-repo folders", async () => { const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoints-nonrepo-")); const store = new FilesystemCheckpointStore(); diff --git a/apps/server/src/filesystemCheckpointStore.ts b/apps/server/src/filesystemCheckpointStore.ts index 99ab6567ad877ff509f9ec53291e1d2514a7adfe..516832bb545c94d8d46c4e29fbe92786f77f6914 100644 --- a/apps/server/src/filesystemCheckpointStore.ts +++ b/apps/server/src/filesystemCheckpointStore.ts @@ -91,10 +91,30 @@ export class FilesystemCheckpointStore { return commit !== null; } + async ensureRootCheckpoint(input: { cwd: string; threadId: string }): Promise { + const { cwd, threadId } = input; + const rootRef = checkpointRefForThreadTurn(threadId, 0); + const existing = await this.resolveCheckpointCommit(cwd, rootRef); + if (existing) { + return true; + } + + const headCommit = await this.resolveHeadCommit(cwd); + if (!headCommit) { + return false; + } + + await this.runGit(cwd, ["update-ref", rootRef, headCommit]); + return true; + } + async restoreCheckpoint(input: { cwd: string; threadId: string; turnCount: number }): Promise { const { cwd, threadId, turnCount } = input; const ref = checkpointRefForThreadTurn(threadId, turnCount); - const commitOid = await this.resolveCheckpointCommit(cwd, ref); + let commitOid = await this.resolveCheckpointCommit(cwd, ref); + if (!commitOid && turnCount === 0) { + commitOid = await this.resolveHeadCommit(cwd); + } if (!commitOid) { return false; } @@ -105,6 +125,64 @@ export class FilesystemCheckpointStore { return true; } + async diffCheckpoints(input: { + cwd: string; + threadId: string; + fromTurnCount: number; + toTurnCount: number; + }): Promise { + const { cwd, threadId, fromTurnCount, toTurnCount } = input; + if (!Number.isInteger(fromTurnCount) || fromTurnCount < 0) { + throw new Error(`Invalid from turn count: ${fromTurnCount}`); + } + if (!Number.isInteger(toTurnCount) || toTurnCount < 0) { + throw new Error(`Invalid to turn count: ${toTurnCount}`); + } + if (fromTurnCount > toTurnCount) { + throw new Error(`Invalid checkpoint range: ${fromTurnCount}..${toTurnCount}`); + } + + const fromRef = checkpointRefForThreadTurn(threadId, fromTurnCount); + const toRef = checkpointRefForThreadTurn(threadId, toTurnCount); + let fromCommitOid = await this.resolveCheckpointCommit(cwd, fromRef); + const toCommitOid = await this.resolveCheckpointCommit(cwd, toRef); + if (!fromCommitOid && fromTurnCount === 0) { + const headCommit = await this.resolveHeadCommit(cwd); + if (headCommit) { + fromCommitOid = headCommit; + } + } + if (!fromCommitOid) { + throw new Error( + `Filesystem checkpoint is unavailable for turn ${fromTurnCount} in thread ${threadId}.`, + ); + } + if (!toCommitOid) { + throw new Error(`Filesystem checkpoint is unavailable for turn ${toTurnCount} in thread ${threadId}.`); + } + + const result = await this.runGit(cwd, [ + "diff", + "--patch", + "--minimal", + "--no-color", + fromCommitOid, + toCommitOid, + ]); + return result.stdout; + } + + private async resolveHeadCommit(cwd: string): Promise { + const result = await this.runGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], { + allowNonZeroExit: true, + }); + if (result.code !== 0) { + return null; + } + const commit = result.stdout.trim(); + return commit.length > 0 ? commit : null; + } + async pruneAfterTurn(input: { cwd: string; threadId: string; maxTurnCount: number }): Promise { const { cwd, threadId, maxTurnCount } = input; if (!Number.isInteger(maxTurnCount) || maxTurnCount < 0) { diff --git a/apps/server/src/providerManager.test.ts b/apps/server/src/providerManager.test.ts index 794e2183d21e039b3bfc27333f42623be521ff5c..66baa5e53fdefd95aa2e11ec7c33261aec5528b4 100644 --- a/apps/server/src/providerManager.test.ts +++ b/apps/server/src/providerManager.test.ts @@ -76,6 +76,14 @@ describe("ProviderManager", () => { }), ).rejects.toThrow("Unknown provider session: missing-session"); + await expect( + manager.getCheckpointDiff({ + sessionId: "missing-session", + fromTurnCount: 0, + toTurnCount: 1, + }), + ).rejects.toThrow("Unknown provider session: missing-session"); + manager.dispose(); }); @@ -155,12 +163,39 @@ describe("ProviderManager", () => { threadId: string; turns: Array<{ id: string; items: unknown[] }>; }>; + listSessions: () => Array<{ + sessionId: string; + provider: "codex"; + status: "ready"; + createdAt: string; + updatedAt: string; + cwd?: string; + }>; + }; + filesystemCheckpointStore: { + isGitRepository: (cwd: string) => Promise; + ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise; }; } ).codex; + const internals = manager as unknown as { + filesystemCheckpointStore: { + isGitRepository: (cwd: string) => Promise; + ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise; + }; + }; const rollbackCalls: Array<{ sessionId: string; numTurns: number }> = []; codex.hasSession = () => true; + codex.listSessions = () => [ + { + sessionId: "sess_1", + provider: "codex", + status: "ready", + createdAt: "2026-02-18T00:00:00.000Z", + updatedAt: "2026-02-18T00:00:00.000Z", + }, + ]; codex.readThread = async () => ({ threadId: "thr_1", turns: [ @@ -176,6 +211,8 @@ describe("ProviderManager", () => { turns: [{ id: "turn_1", items: [] }], }; }; + internals.filesystemCheckpointStore.isGitRepository = async () => false; + internals.filesystemCheckpointStore.ensureRootCheckpoint = async () => true; const result = await manager.revertToCheckpoint({ sessionId: "sess_1", @@ -192,6 +229,239 @@ describe("ProviderManager", () => { manager.dispose(); }); + it("returns diff text for a checkpoint range", async () => { + const manager = new ProviderManager(); + const internals = manager as unknown as { + codex: { + hasSession: (sessionId: string) => boolean; + readThread: (sessionId: string) => Promise<{ + threadId: string; + turns: Array<{ id: string; items: unknown[] }>; + }>; + }; + filesystemCheckpointStore: { + diffCheckpoints: (input: { + cwd: string; + threadId: string; + fromTurnCount: number; + toTurnCount: number; + }) => Promise; + }; + sessionCheckpointCwds: Map; + }; + + internals.codex.hasSession = () => true; + internals.codex.readThread = async () => ({ + threadId: "thr_1", + turns: [{ id: "turn_1", items: [] }, { id: "turn_2", items: [] }], + }); + const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts"); + internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints; + internals.sessionCheckpointCwds.set("sess_1", "/repo"); + + const result = await manager.getCheckpointDiff({ + sessionId: "sess_1", + fromTurnCount: 1, + toTurnCount: 2, + }); + + expect(result).toEqual({ + threadId: "thr_1", + fromTurnCount: 1, + toTurnCount: 2, + diff: "diff --git a/a.ts b/a.ts", + }); + expect(diffCheckpoints).toHaveBeenCalledWith({ + cwd: "/repo", + threadId: "thr_1", + fromTurnCount: 1, + toTurnCount: 2, + }); + + manager.dispose(); + }); + + it("lazily initializes filesystem checkpoints before diffing when cache is missing", async () => { + const manager = new ProviderManager(); + const internals = manager as unknown as { + codex: { + hasSession: (sessionId: string) => boolean; + listSessions: () => Array<{ + sessionId: string; + provider: "codex"; + status: "ready"; + createdAt: string; + updatedAt: string; + cwd?: string; + }>; + readThread: (sessionId: string) => Promise<{ + threadId: string; + turns: Array<{ id: string; items: unknown[] }>; + }>; + }; + filesystemCheckpointStore: { + isGitRepository: (cwd: string) => Promise; + ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise; + captureCheckpoint: (input: { + cwd: string; + threadId: string; + turnCount: number; + }) => Promise; + diffCheckpoints: (input: { + cwd: string; + threadId: string; + fromTurnCount: number; + toTurnCount: number; + }) => Promise; + }; + sessionCheckpointCwds: Map; + }; + + internals.codex.hasSession = () => true; + internals.codex.listSessions = () => [ + { + sessionId: "sess_1", + provider: "codex", + status: "ready", + createdAt: "2026-02-18T00:00:00.000Z", + updatedAt: "2026-02-18T00:00:00.000Z", + cwd: "/repo", + }, + ]; + internals.codex.readThread = async () => ({ + threadId: "thr_1", + turns: [{ id: "turn_1", items: [] }, { id: "turn_2", items: [] }], + }); + const isGitRepository = vi.fn(async () => true); + const ensureRootCheckpoint = vi.fn(async () => true); + const captureCheckpoint = vi.fn(async () => undefined); + const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts"); + internals.filesystemCheckpointStore.isGitRepository = isGitRepository; + internals.filesystemCheckpointStore.ensureRootCheckpoint = ensureRootCheckpoint; + internals.filesystemCheckpointStore.captureCheckpoint = captureCheckpoint; + internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints; + internals.sessionCheckpointCwds.delete("sess_1"); + + const result = await manager.getCheckpointDiff({ + sessionId: "sess_1", + fromTurnCount: 1, + toTurnCount: 2, + }); + + expect(result.diff).toBe("diff --git a/a.ts b/a.ts"); + expect(isGitRepository).toHaveBeenCalledWith("/repo"); + expect(captureCheckpoint).toHaveBeenCalledWith({ + cwd: "/repo", + threadId: "thr_1", + turnCount: 2, + }); + expect(ensureRootCheckpoint).toHaveBeenCalledWith({ + cwd: "/repo", + threadId: "thr_1", + }); + expect(diffCheckpoints).toHaveBeenCalledWith({ + cwd: "/repo", + threadId: "thr_1", + fromTurnCount: 1, + toTurnCount: 2, + }); + expect(internals.sessionCheckpointCwds.get("sess_1")).toBe("/repo"); + + manager.dispose(); + }); + + it("falls back to process cwd for lazy checkpoint init when session cwd is missing", async () => { + const manager = new ProviderManager(); + const internals = manager as unknown as { + codex: { + hasSession: (sessionId: string) => boolean; + listSessions: () => Array<{ + sessionId: string; + provider: "codex"; + status: "ready"; + createdAt: string; + updatedAt: string; + cwd?: string; + }>; + readThread: (sessionId: string) => Promise<{ + threadId: string; + turns: Array<{ id: string; items: unknown[] }>; + }>; + }; + filesystemCheckpointStore: { + isGitRepository: (cwd: string) => Promise; + ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise; + captureCheckpoint: (input: { + cwd: string; + threadId: string; + turnCount: number; + }) => Promise; + diffCheckpoints: (input: { + cwd: string; + threadId: string; + fromTurnCount: number; + toTurnCount: number; + }) => Promise; + }; + sessionCheckpointCwds: Map; + }; + + const processCwd = vi.spyOn(process, "cwd").mockReturnValue("/fallback-repo"); + try { + internals.codex.hasSession = () => true; + internals.codex.listSessions = () => [ + { + sessionId: "sess_1", + provider: "codex", + status: "ready", + createdAt: "2026-02-18T00:00:00.000Z", + updatedAt: "2026-02-18T00:00:00.000Z", + }, + ]; + internals.codex.readThread = async () => ({ + threadId: "thr_1", + turns: [{ id: "turn_1", items: [] }], + }); + const isGitRepository = vi.fn(async (cwd: string) => cwd === "/fallback-repo"); + const ensureRootCheckpoint = vi.fn(async () => true); + const captureCheckpoint = vi.fn(async () => undefined); + const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts"); + internals.filesystemCheckpointStore.isGitRepository = isGitRepository; + internals.filesystemCheckpointStore.ensureRootCheckpoint = ensureRootCheckpoint; + internals.filesystemCheckpointStore.captureCheckpoint = captureCheckpoint; + internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints; + internals.sessionCheckpointCwds.delete("sess_1"); + + const result = await manager.getCheckpointDiff({ + sessionId: "sess_1", + fromTurnCount: 0, + toTurnCount: 1, + }); + + expect(result.diff).toBe("diff --git a/a.ts b/a.ts"); + expect(isGitRepository).toHaveBeenCalledWith("/fallback-repo"); + expect(captureCheckpoint).toHaveBeenCalledWith({ + cwd: "/fallback-repo", + threadId: "thr_1", + turnCount: 1, + }); + expect(ensureRootCheckpoint).toHaveBeenCalledWith({ + cwd: "/fallback-repo", + threadId: "thr_1", + }); + expect(diffCheckpoints).toHaveBeenCalledWith({ + cwd: "/fallback-repo", + threadId: "thr_1", + fromTurnCount: 0, + toTurnCount: 1, + }); + expect(internals.sessionCheckpointCwds.get("sess_1")).toBe("/fallback-repo"); + } finally { + processCwd.mockRestore(); + manager.dispose(); + } + }); + it("restores filesystem checkpoint when reverting and checkpointing is enabled", async () => { const manager = new ProviderManager(); const internals = manager as unknown as { @@ -263,6 +533,73 @@ describe("ProviderManager", () => { manager.dispose(); }); + it("reverts to turn 0 without requiring an explicit turn 0 checkpoint ref", async () => { + const manager = new ProviderManager(); + const internals = manager as unknown as { + codex: { + hasSession: (sessionId: string) => boolean; + readThread: (sessionId: string) => Promise<{ + threadId: string; + turns: Array<{ id: string; items: unknown[] }>; + }>; + rollbackThread: (sessionId: string, numTurns: number) => Promise<{ + threadId: string; + turns: Array<{ id: string; items: unknown[] }>; + }>; + }; + filesystemCheckpointStore: { + hasCheckpoint: (input: { cwd: string; threadId: string; turnCount: number }) => Promise; + restoreCheckpoint: (input: { + cwd: string; + threadId: string; + turnCount: number; + }) => Promise; + pruneAfterTurn: (input: { cwd: string; threadId: string; maxTurnCount: number }) => Promise; + }; + sessionCheckpointCwds: Map; + }; + + internals.codex.hasSession = () => true; + internals.codex.readThread = async () => ({ + threadId: "thr_1", + turns: [ + { id: "turn_1", items: [] }, + { id: "turn_2", items: [] }, + ], + }); + internals.codex.rollbackThread = async () => ({ + threadId: "thr_1", + turns: [], + }); + const hasCheckpoint = vi.fn(async () => false); + const restoreCheckpoint = vi.fn(async () => true); + const pruneAfterTurn = vi.fn(async () => undefined); + internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint; + internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint; + internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn; + internals.sessionCheckpointCwds.set("sess_1", "/repo"); + + const result = await manager.revertToCheckpoint({ + sessionId: "sess_1", + turnCount: 0, + }); + + expect(result.turnCount).toBe(0); + expect(hasCheckpoint).not.toHaveBeenCalled(); + expect(restoreCheckpoint).toHaveBeenCalledWith({ + cwd: "/repo", + threadId: "thr_1", + turnCount: 0, + }); + expect(pruneAfterTurn).toHaveBeenCalledWith({ + cwd: "/repo", + threadId: "thr_1", + maxTurnCount: 0, + }); + + manager.dispose(); + }); + it("fails before rollback when filesystem checkpoint is missing", async () => { const manager = new ProviderManager(); const internals = manager as unknown as { diff --git a/apps/server/src/providerManager.ts b/apps/server/src/providerManager.ts index 1038ed41d4f2852e2e3015708c0d347736a1c494..5e55f0b1ce45188a081968dcb36efe3234689721 100644 --- a/apps/server/src/providerManager.ts +++ b/apps/server/src/providerManager.ts @@ -6,6 +6,8 @@ import { randomUUID } from "node:crypto"; import { type ProviderCheckpoint, type ProviderEvent, + type ProviderGetCheckpointDiffInput, + type ProviderGetCheckpointDiffResult, type ProviderInterruptTurnInput, type ProviderListCheckpointsInput, type ProviderListCheckpointsResult, @@ -18,6 +20,7 @@ import { type ProviderStopSessionInput, type ProviderTurnStartResult, providerInterruptTurnInputSchema, + providerGetCheckpointDiffInputSchema, providerListCheckpointsInputSchema, providerRevertToCheckpointInputSchema, providerRespondToRequestInputSchema, @@ -198,7 +201,7 @@ export class ProviderManager extends EventEmitter { this.sessionThreadIds.set(session.sessionId, session.threadId); this.flushPendingSessionEvents(session.sessionId, session.threadId); } - await this.initializeFilesystemCheckpointing(session).catch((error) => { + await this.initializeFilesystemCheckpointing(session, input.cwd).catch((error) => { const message = error instanceof Error ? error.message : "Failed to initialize filesystem checkpoints."; this.emitFilesystemCheckpointError(session.sessionId, message, session.threadId); @@ -259,6 +262,43 @@ export class ProviderManager extends EventEmitter { }; } + async getCheckpointDiff( + raw: ProviderGetCheckpointDiffInput, + ): Promise { + const input = providerGetCheckpointDiffInputSchema.parse(raw); + if (!this.codex.hasSession(input.sessionId)) { + throw new Error(`Unknown provider session: ${input.sessionId}`); + } + + const checkpointCwd = await this.getOrInitializeFilesystemCheckpointCwd(input.sessionId); + if (!checkpointCwd) { + throw new Error("Filesystem checkpoints are unavailable for this session."); + } + + return this.withFilesystemLock(input.sessionId, async () => { + const snapshot = await this.codex.readThread(input.sessionId); + if (input.toTurnCount > snapshot.turns.length) { + throw new Error( + `Checkpoint turn count ${input.toTurnCount} exceeds current turn count ${snapshot.turns.length}.`, + ); + } + + const diff = await this.filesystemCheckpointStore.diffCheckpoints({ + cwd: checkpointCwd, + threadId: snapshot.threadId, + fromTurnCount: input.fromTurnCount, + toTurnCount: input.toTurnCount, + }); + + return { + threadId: snapshot.threadId, + fromTurnCount: input.fromTurnCount, + toTurnCount: input.toTurnCount, + diff, + }; + }); + } + async revertToCheckpoint( raw: ProviderRevertToCheckpointInput, ): Promise { @@ -267,6 +307,7 @@ export class ProviderManager extends EventEmitter { throw new Error(`Unknown provider session: ${input.sessionId}`); } + const checkpointCwd = await this.getOrInitializeFilesystemCheckpointCwd(input.sessionId); return this.withFilesystemLock(input.sessionId, async () => { const beforeSnapshot = await this.codex.readThread(input.sessionId); const currentTurnCount = beforeSnapshot.turns.length; @@ -276,8 +317,7 @@ export class ProviderManager extends EventEmitter { ); } - const checkpointCwd = this.sessionCheckpointCwds.get(input.sessionId); - if (checkpointCwd) { + if (checkpointCwd && input.turnCount > 0) { const hasCheckpoint = await this.filesystemCheckpointStore.hasCheckpoint({ cwd: checkpointCwd, threadId: beforeSnapshot.threadId, @@ -352,8 +392,11 @@ export class ProviderManager extends EventEmitter { this.filesystemLocks.clear(); } - private async initializeFilesystemCheckpointing(session: ProviderSession): Promise { - const cwd = session.cwd ?? process.cwd(); + private async initializeFilesystemCheckpointing( + session: ProviderSession, + preferredCwd?: string, + ): Promise { + const cwd = preferredCwd ?? session.cwd ?? process.cwd(); await this.withFilesystemLock(session.sessionId, async () => { const supportsGit = await this.filesystemCheckpointStore.isGitRepository(cwd); @@ -363,6 +406,10 @@ export class ProviderManager extends EventEmitter { } const snapshot = await this.codex.readThread(session.sessionId); + await this.filesystemCheckpointStore.ensureRootCheckpoint({ + cwd, + threadId: snapshot.threadId, + }); await this.filesystemCheckpointStore.captureCheckpoint({ cwd, threadId: snapshot.threadId, @@ -372,6 +419,54 @@ export class ProviderManager extends EventEmitter { }); } + private async getOrInitializeFilesystemCheckpointCwd(sessionId: string): Promise { + const existingCwd = this.sessionCheckpointCwds.get(sessionId); + if (existingCwd) { + return existingCwd; + } + + const session = this.codex.listSessions().find((candidate) => candidate.sessionId === sessionId); + const candidateCwds = Array.from( + new Set([session?.cwd, process.cwd()].filter((value): value is string => Boolean(value))), + ); + if (candidateCwds.length === 0) { + return null; + } + + await this.withFilesystemLock(sessionId, async () => { + const currentCwd = this.sessionCheckpointCwds.get(sessionId); + if (currentCwd) { + return; + } + + const cwdSupport = await Promise.all( + candidateCwds.map(async (cwd) => ({ + cwd, + supportsGit: await this.filesystemCheckpointStore.isGitRepository(cwd), + })), + ); + const supportedCwd = cwdSupport.find((entry) => entry.supportsGit)?.cwd; + if (supportedCwd) { + const snapshot = await this.codex.readThread(sessionId); + await this.filesystemCheckpointStore.ensureRootCheckpoint({ + cwd: supportedCwd, + threadId: snapshot.threadId, + }); + await this.filesystemCheckpointStore.captureCheckpoint({ + cwd: supportedCwd, + threadId: snapshot.threadId, + turnCount: snapshot.turns.length, + }); + this.sessionCheckpointCwds.set(sessionId, supportedCwd); + return; + } + + this.sessionCheckpointCwds.delete(sessionId); + }); + + return this.sessionCheckpointCwds.get(sessionId) ?? null; + } + private maybeCaptureFilesystemCheckpoint(event: ProviderEvent): void { if (event.kind !== "notification" || event.method !== "turn/completed") { return; @@ -379,6 +474,11 @@ export class ProviderManager extends EventEmitter { const checkpointCwd = this.sessionCheckpointCwds.get(event.sessionId); if (!checkpointCwd) { + void this.getOrInitializeFilesystemCheckpointCwd(event.sessionId).catch((error) => { + const message = + error instanceof Error ? error.message : "Failed to initialize filesystem checkpoints."; + this.emitFilesystemCheckpointError(event.sessionId, message, event.threadId); + }); return; } diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index a860c5d8ae20143b1d1eeff0c36e7b60a1bf167d..48782bd26e404994d6471aaf02fd0d26546e4d19 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -668,6 +668,14 @@ describe("WebSocket Server", () => { }); expect(revertResponse.result).toBeUndefined(); expect(revertResponse.error?.message).toContain("Unknown provider session"); + + const diffResponse = await sendRequest(ws, WS_METHODS.providersGetCheckpointDiff, { + sessionId: "missing-session", + fromTurnCount: 0, + toTurnCount: 1, + }); + expect(diffResponse.result).toBeUndefined(); + expect(diffResponse.error?.message).toContain("Unknown provider session"); }); it("routes terminal RPC methods and broadcasts terminal events", async () => { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index ec0d73d59e3b7652c37020559f312a5219758aa8..325727827d2fb6a3e68106b4d0c8e84a8fd1b29e 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -313,6 +313,9 @@ export function createServer(options: ServerOptions) { case WS_METHODS.providersListCheckpoints: return providerManager.listCheckpoints(request.params as never); + case WS_METHODS.providersGetCheckpointDiff: + return providerManager.getCheckpointDiff(request.params as never); + case WS_METHODS.providersRevertToCheckpoint: return providerManager.revertToCheckpoint(request.params as never); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 6a3d09363d20dc77c7d1211e3f7b0d922d9c6c54..13058c114e267669e256fd4fee8dbb84f7f00e16 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -9,10 +9,13 @@ import { StoreProvider, useStore } from "./store"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types"; import { onServerWelcome } from "./wsNativeApi"; import { useNativeApi } from "./hooks/useNativeApi"; +import { useMediaQuery } from "./hooks/useMediaQuery"; import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast"; +import { Sheet, SheetPopup } from "./components/ui/sheet"; import { invalidateGitQueries } from "./lib/gitReactQuery"; const DiffPanel = lazy(() => import("./components/DiffPanel")); +const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; function EventRouter() { const api = useNativeApi(); @@ -112,6 +115,7 @@ function AutoProjectBootstrap() { session: null, messages: [], events: [], + turnDiffSummaries: [], error: null, createdAt: new Date().toISOString(), branch: null, @@ -177,7 +181,8 @@ function DesktopProjectBootstrap() { function Layout() { const api = useNativeApi(); - const { state } = useStore(); + const { state, dispatch } = useStore(); + const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); if (!api) { return ( @@ -196,7 +201,7 @@ function Layout() { - {state.diffOpen && ( + {state.diffOpen && !shouldUseDiffSheet && ( @@ -204,9 +209,35 @@ function Layout() { } > - + )} + {state.diffOpen && shouldUseDiffSheet && ( + { + if (!open) { + dispatch({ type: "CLOSE_DIFF" }); + } + }} + > + + + Loading diff viewer... + + } + > + + + + + )} ); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index dda7d6a5ae78b3b4db589b17fab8d4b335c49d47..a053dc732452c498e0251857f810235274513065 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8,7 +8,6 @@ import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, - type ProviderCheckpoint, type ResolvedKeybindingsConfig, type ProviderApprovalDecision, type ProviderSendTurnAttachmentInput, @@ -50,10 +49,12 @@ import { resolveModelSlug, } from "../model-logic"; import { + countDiffStat, derivePendingApprovals, derivePhase, deriveTurnDiffSummaries, deriveTimelineEntries, + inferCheckpointTurnCountByTurnId, type TurnDiffSummary, type PendingApproval, deriveWorkLogEntries, @@ -87,6 +88,7 @@ import { CircleAlertIcon, FileIcon, FolderIcon, + DiffIcon, FolderClosedIcon, InfoIcon, LockIcon, @@ -348,9 +350,7 @@ export default function ChatView() { const [expandedImage, setExpandedImage] = useState(null); const [isSending, setIsSending] = useState(false); const [isConnecting, setIsConnecting] = useState(false); - const [isLoadingCheckpoints, setIsLoadingCheckpoints] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); - const [checkpoints, setCheckpoints] = useState([]); const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); const [envMode, setEnvMode] = useState<"local" | "worktree">("local"); const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); @@ -371,10 +371,13 @@ export default function ChatView() { const composerImagesRef = useRef([]); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); + const checkpointDiffRequestsRef = useRef(new Set()); + const checkpointHydrationSessionRequestRef = useRef(new Set()); const activeThread = state.threads.find((t) => t.id === state.activeThreadId); const activeThreadId = activeThread?.id ?? null; const activeSessionId = activeThread?.session?.sessionId; + const activeThreadRuntimeId = activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); const selectedModel = resolveModelSlug( activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, @@ -409,8 +412,11 @@ export default function ChatView() { [activeThread?.messages, workLogEntries], ); const turnDiffSummaries = useMemo( - () => deriveTurnDiffSummaries(activeThread?.events ?? []), - [activeThread?.events], + () => + activeThread?.turnDiffSummaries.length + ? activeThread.turnDiffSummaries + : deriveTurnDiffSummaries(activeThread?.events ?? []), + [activeThread?.events, activeThread?.turnDiffSummaries], ); const turnDiffSummaryByAssistantMessageId = useMemo(() => { const byMessageId = new Map(); @@ -420,6 +426,134 @@ export default function ChatView() { } return byMessageId; }, [turnDiffSummaries]); + const inferredCheckpointTurnCountByTurnId = useMemo( + () => inferCheckpointTurnCountByTurnId(turnDiffSummaries), + [turnDiffSummaries], + ); + const revertTurnCountByUserMessageId = useMemo(() => { + const byUserMessageId = new Map(); + for (let index = 0; index < timelineEntries.length; index += 1) { + const entry = timelineEntries[index]; + if (!entry || entry.kind !== "message" || entry.message.role !== "user") { + continue; + } + + for (let nextIndex = index + 1; nextIndex < timelineEntries.length; nextIndex += 1) { + const nextEntry = timelineEntries[nextIndex]; + if (!nextEntry || nextEntry.kind !== "message") { + continue; + } + if (nextEntry.message.role === "user") { + break; + } + const summary = turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id); + if (!summary) { + continue; + } + const turnCount = + summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; + if (typeof turnCount !== "number") { + break; + } + byUserMessageId.set(entry.message.id, Math.max(0, turnCount - 1)); + break; + } + } + + return byUserMessageId; + }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); + + useEffect(() => { + if (!activeThreadId || turnDiffSummaries.length === 0) { + return; + } + + const inferredMissingTurnCounts = turnDiffSummaries.reduce>( + (acc, summary) => { + if (typeof summary.checkpointTurnCount === "number") { + return acc; + } + const inferredTurnCount = inferredCheckpointTurnCountByTurnId[summary.turnId]; + if (typeof inferredTurnCount !== "number") { + return acc; + } + acc[summary.turnId] = inferredTurnCount; + return acc; + }, + {}, + ); + if (Object.keys(inferredMissingTurnCounts).length === 0) { + return; + } + + dispatch({ + type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", + threadId: activeThreadId, + checkpointTurnCountByTurnId: inferredMissingTurnCounts, + }); + }, [activeThreadId, dispatch, inferredCheckpointTurnCountByTurnId, turnDiffSummaries]); + + useEffect(() => { + if (!api || !activeThreadId || !activeSessionId || turnDiffSummaries.length === 0) { + return; + } + + const turnSummariesNeedingDiff = turnDiffSummaries.filter( + (summary) => !summary.checkpointDiffLoaded && inferredCheckpointTurnCountByTurnId[summary.turnId], + ); + const requestedSummaries = turnSummariesNeedingDiff.filter((summary) => { + const requestKey = `${activeThreadId}:${summary.turnId}`; + if (checkpointDiffRequestsRef.current.has(requestKey)) { + return false; + } + checkpointDiffRequestsRef.current.add(requestKey); + return true; + }); + if (requestedSummaries.length === 0) { + return; + } + + let cancelled = false; + void Promise.all( + requestedSummaries.map(async (summary) => { + const turnCount = inferredCheckpointTurnCountByTurnId[summary.turnId] ?? 0; + const result = await api.providers.getCheckpointDiff({ + sessionId: activeSessionId, + fromTurnCount: Math.max(0, turnCount - 1), + toTurnCount: turnCount, + }); + return [summary.turnId, result.diff] as const; + }), + ) + .then((entries) => { + if (cancelled) return; + dispatch({ + type: "SET_THREAD_TURN_CHECKPOINT_DIFFS", + threadId: activeThreadId, + checkpointDiffByTurnId: Object.fromEntries(entries), + }); + }) + .catch(() => { + if (cancelled) return; + }) + .finally(() => { + for (const summary of requestedSummaries) { + checkpointDiffRequestsRef.current.delete(`${activeThreadId}:${summary.turnId}`); + } + }); + + return () => { + cancelled = true; + }; + }, [ + activeSessionId, + activeThreadId, + api, + dispatch, + inferredCheckpointTurnCountByTurnId, + turnDiffSummaries, + ]); + const completionSummary = useMemo(() => { if (!activeThread?.latestTurnStartedAt) return null; if (!activeThread.latestTurnCompletedAt) return null; @@ -475,16 +609,9 @@ export default function ChatView() { completionSummary, timelineEntries, ]); - const runtimeSessionConfig = - state.runtimeMode === "full-access" - ? ({ - approvalPolicy: "never", - sandboxMode: "danger-full-access", - } as const) - : ({ - approvalPolicy: "on-request", - sandboxMode: "workspace-write", - } as const); + const runtimeApprovalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request"; + const runtimeSandboxMode = + state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write"; const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; const composerTrigger = useMemo( () => detectComposerTrigger(prompt, composerCursor), @@ -593,12 +720,6 @@ export default function ChatView() { ); const hasReachedTerminalLimit = (activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT; - const canCheckpoint = - Boolean(api && activeThread && (activeThread.codexThreadId ?? activeThread.session?.threadId)) && - phase !== "running" && - !isSending && - !isConnecting && - !isRevertingCheckpoint; const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => { for (const image of images) { @@ -988,8 +1109,6 @@ export default function ChatView() { }, [composerMenuItems, composerMenuOpen]); useEffect(() => { - setCheckpoints([]); - setIsLoadingCheckpoints(false); setIsRevertingCheckpoint(false); }, [activeThread?.id]); @@ -1172,14 +1291,17 @@ export default function ChatView() { toggleTerminalVisibility, ]); - const setThreadError = (threadId: string | null, error: string | null) => { - if (!threadId) return; - dispatch({ - type: "SET_ERROR", - threadId, - error, - }); - }; + const setThreadError = useCallback( + (threadId: string | null, error: string | null) => { + if (!threadId) return; + dispatch({ + type: "SET_ERROR", + threadId, + error, + }); + }, + [dispatch], + ); const addComposerImages = (files: File[]) => { if (!activeThreadId || files.length === 0) return; @@ -1284,134 +1406,169 @@ export default function ChatView() { focusComposer(); }; - const ensureSession = async (cwdOverride?: string): Promise => { - if (!api || !activeThread || !activeProject) return null; - if (activeThread.session && activeThread.session.status !== "closed") { - const sessionThreadId = activeThread.session.threadId ?? null; - const continuityState: SessionContinuityState = - activeThread.codexThreadId === null - ? "new" - : sessionThreadId === activeThread.codexThreadId - ? "resumed" - : "fallback_new"; - return { - sessionId: activeThread.session.sessionId, - resolvedThreadId: sessionThreadId, - continuityState, - } satisfies EnsuredSessionInfo; - } + const ensureSession = useCallback( + async (cwdOverride?: string): Promise => { + if (!api || !activeThread || !activeProject) return null; + if (activeThread.session && activeThread.session.status !== "closed") { + const sessionThreadId = activeThread.session.threadId ?? null; + const continuityState: SessionContinuityState = + activeThread.codexThreadId === null + ? "new" + : sessionThreadId === activeThread.codexThreadId + ? "resumed" + : "fallback_new"; + return { + sessionId: activeThread.session.sessionId, + resolvedThreadId: sessionThreadId, + continuityState, + } satisfies EnsuredSessionInfo; + } - const priorCodexThreadId = activeThread.codexThreadId; - setIsConnecting(true); - try { - const session = await api.providers.startSession({ - provider: "codex", - cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd, - model: selectedModel || undefined, - resumeThreadId: priorCodexThreadId ?? undefined, - approvalPolicy: runtimeSessionConfig.approvalPolicy, - sandboxMode: runtimeSessionConfig.sandboxMode, - }); - dispatch({ - type: "UPDATE_SESSION", - threadId: activeThread.id, - session, - }); - const resolvedThreadId = session.threadId ?? null; - const continuityState: SessionContinuityState = - priorCodexThreadId === null - ? "new" - : resolvedThreadId === priorCodexThreadId - ? "resumed" - : "fallback_new"; - return { - sessionId: session.sessionId, - resolvedThreadId, - continuityState, - }; - } catch (err) { - dispatch({ - type: "SET_ERROR", - threadId: activeThread.id, - error: err instanceof Error ? err.message : "Failed to connect.", - }); - return null; - } finally { - setIsConnecting(false); - } - }; - - const loadCheckpoints = async (): Promise => { - if (!api || !activeThread) return null; - if (phase === "running" || isSending || isConnecting || isRevertingCheckpoint) { - return null; - } - const hasThreadIdentity = Boolean(activeThread.codexThreadId ?? activeThread.session?.threadId); - if (!hasThreadIdentity) { - setCheckpoints([]); - return []; - } - - setIsLoadingCheckpoints(true); - setThreadError(activeThread.id, null); - try { - const sessionInfo = await ensureSession(); - if (!sessionInfo) { + const priorCodexThreadId = activeThread.codexThreadId; + setIsConnecting(true); + try { + const session = await api.providers.startSession({ + provider: "codex", + cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd, + model: selectedModel || undefined, + resumeThreadId: priorCodexThreadId ?? undefined, + approvalPolicy: runtimeApprovalPolicy, + sandboxMode: runtimeSandboxMode, + }); + dispatch({ + type: "UPDATE_SESSION", + threadId: activeThread.id, + session, + }); + const resolvedThreadId = session.threadId ?? null; + const continuityState: SessionContinuityState = + priorCodexThreadId === null + ? "new" + : resolvedThreadId === priorCodexThreadId + ? "resumed" + : "fallback_new"; + return { + sessionId: session.sessionId, + resolvedThreadId, + continuityState, + }; + } catch (err) { + dispatch({ + type: "SET_ERROR", + threadId: activeThread.id, + error: err instanceof Error ? err.message : "Failed to connect.", + }); return null; + } finally { + setIsConnecting(false); } - const result = await api.providers.listCheckpoints({ - sessionId: sessionInfo.sessionId, - }); - setCheckpoints(result.checkpoints); - return result.checkpoints; - } catch (err) { - setThreadError( - activeThread.id, - err instanceof Error ? err.message : "Failed to load checkpoints.", - ); - return null; - } finally { - setIsLoadingCheckpoints(false); - } - }; + }, + [activeProject, activeThread, api, dispatch, runtimeApprovalPolicy, runtimeSandboxMode, selectedModel], + ); - const onRevertToCheckpoint = async (checkpoint: ProviderCheckpoint) => { - if (!api || !activeThread || checkpoint.isCurrent || isRevertingCheckpoint) { - return; - } - if (phase === "running" || isSending || isConnecting) { - setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); + useEffect(() => { + if (!api || !activeThreadId || activeSessionId) return; + if (phase === "running" || isSending || isConnecting || isRevertingCheckpoint) return; + const hasThreadIdentity = Boolean(activeThreadRuntimeId); + if (!hasThreadIdentity) return; + const selectedDiffTurnId = + state.diffOpen && state.diffThreadId === activeThreadId ? state.diffTurnId : null; + const selectedDiffTurn = + turnDiffSummaries.find((summary) => summary.turnId === selectedDiffTurnId) ?? turnDiffSummaries[0]; + const selectedTurnMissingPatchBody = Boolean( + selectedDiffTurn && + !selectedDiffTurn.unifiedDiff && + selectedDiffTurn.files.every((file) => !file.diff), + ); + const hasPendingDiffHydration = + turnDiffSummaries.some((summary) => !summary.checkpointDiffLoaded) || selectedTurnMissingPatchBody; + if (!hasPendingDiffHydration) return; + + const requestKey = `${activeThreadId}:${activeThreadRuntimeId ?? "none"}`; + if (checkpointHydrationSessionRequestRef.current.has(requestKey)) { return; } + checkpointHydrationSessionRequestRef.current.add(requestKey); + setThreadError(activeThreadId, null); + void ensureSession().finally(() => { + checkpointHydrationSessionRequestRef.current.delete(requestKey); + }); + }, [ + activeThreadId, + activeThreadRuntimeId, + activeSessionId, + api, + ensureSession, + isConnecting, + isRevertingCheckpoint, + isSending, + phase, + setThreadError, + state.diffOpen, + state.diffThreadId, + state.diffTurnId, + turnDiffSummaries, + ]); - setIsRevertingCheckpoint(true); - setThreadError(activeThread.id, null); - try { - const sessionInfo = await ensureSession(); - if (!sessionInfo) { + const onRevertToTurnCount = useCallback( + async (turnCount: number) => { + if (!api || !activeThread || isRevertingCheckpoint) { return; } - const result = await api.providers.revertToCheckpoint({ - sessionId: sessionInfo.sessionId, - turnCount: checkpoint.turnCount, - }); - dispatch({ - type: "REVERT_TO_CHECKPOINT", - threadId: activeThread.id, - sessionId: sessionInfo.sessionId, - threadRuntimeId: result.threadId, - messageCount: result.messageCount, - }); - setCheckpoints(result.checkpoints); - } catch (err) { - setThreadError( - activeThread.id, - err instanceof Error ? err.message : "Failed to revert checkpoint.", - ); - } finally { - setIsRevertingCheckpoint(false); - } - }; + if (phase === "running" || isSending || isConnecting) { + setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); + return; + } + + setIsRevertingCheckpoint(true); + setThreadError(activeThread.id, null); + try { + const sessionInfo = await ensureSession(); + if (!sessionInfo) { + return; + } + const result = await api.providers.revertToCheckpoint({ + sessionId: sessionInfo.sessionId, + turnCount, + }); + dispatch({ + type: "REVERT_TO_CHECKPOINT", + threadId: activeThread.id, + sessionId: sessionInfo.sessionId, + threadRuntimeId: result.threadId, + turnCount: result.turnCount, + messageCount: result.messageCount, + }); + dispatch({ + type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", + threadId: activeThread.id, + checkpointTurnCountByTurnId: Object.fromEntries( + result.checkpoints + .filter((entry) => entry.turnCount > 0) + .map((entry) => [entry.id, entry.turnCount] as const), + ), + }); + } catch (err) { + setThreadError( + activeThread.id, + err instanceof Error ? err.message : "Failed to revert thread state.", + ); + } finally { + setIsRevertingCheckpoint(false); + } + }, + [ + activeThread, + api, + dispatch, + ensureSession, + isConnecting, + isRevertingCheckpoint, + isSending, + phase, + setThreadError, + ], + ); const onSend = async (e: FormEvent) => { e.preventDefault(); @@ -1704,8 +1861,7 @@ export default function ChatView() { return; } - const latestSummary = turnDiffSummaries[0]; - if (!activeThread || !latestSummary) { + if (!activeThread) { dispatch({ type: "TOGGLE_DIFF" }); return; } @@ -1713,10 +1869,8 @@ export default function ChatView() { dispatch({ type: "OPEN_DIFF", threadId: activeThread.id, - turnId: latestSummary.turnId, - ...(latestSummary.files[0]?.path ? { filePath: latestSummary.files[0].path } : {}), }); - }, [activeThread, dispatch, state.diffOpen, turnDiffSummaries]); + }, [activeThread, dispatch, state.diffOpen]); const onOpenTurnDiff = useCallback( (turnId: string, filePath?: string) => { if (!activeThread) return; @@ -1729,6 +1883,16 @@ export default function ChatView() { }, [activeThread, dispatch], ); + const onRevertUserMessage = useCallback( + (messageId: string) => { + const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); + if (typeof targetTurnCount !== "number") { + return; + } + void onRevertToTurnCount(targetTurnCount); + }, + [onRevertToTurnCount, revertTurnCountByUserMessageId], + ); // Empty state: no active thread if (!activeThread) { @@ -1771,14 +1935,6 @@ export default function ChatView() { onAddProjectScript={saveProjectScript} onUpdateProjectScript={updateProjectScript} onToggleDiff={onToggleDiff} - checkpoints={checkpoints} - canCheckpoint={canCheckpoint} - isLoadingCheckpoints={isLoadingCheckpoints} - isRevertingCheckpoint={isRevertingCheckpoint} - onOpenCheckpoints={() => { - void loadCheckpoints(); - }} - onRevertToCheckpoint={onRevertToCheckpoint} /> @@ -1808,6 +1964,9 @@ export default function ChatView() { expandedWorkGroups={expandedWorkGroups} onToggleWorkGroup={onToggleWorkGroup} onOpenTurnDiff={onOpenTurnDiff} + revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} + onRevertUserMessage={onRevertUserMessage} + isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} messagesEndRef={messagesEndRef} /> @@ -2114,12 +2273,6 @@ interface ChatHeaderProps { onAddProjectScript: (input: NewProjectScriptInput) => Promise; onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise; onToggleDiff: () => void; - checkpoints: ProviderCheckpoint[]; - canCheckpoint: boolean; - isLoadingCheckpoints: boolean; - isRevertingCheckpoint: boolean; - onOpenCheckpoints: () => void; - onRevertToCheckpoint: (checkpoint: ProviderCheckpoint) => void; } const ChatHeader = memo(function ChatHeader({ @@ -2135,12 +2288,6 @@ const ChatHeader = memo(function ChatHeader({ onAddProjectScript, onUpdateProjectScript, onToggleDiff, - checkpoints, - canCheckpoint, - isLoadingCheckpoints, - isRevertingCheckpoint, - onOpenCheckpoints, - onRevertToCheckpoint, }: ChatHeaderProps) { return ( <> @@ -2163,56 +2310,14 @@ const ChatHeader = memo(function ChatHeader({ )} {activeProjectName && } {activeProjectName && } - - - {isRevertingCheckpoint - ? "Reverting..." - : isLoadingCheckpoints - ? "Loading..." - : "Checkpoints"} - - } - /> - - {!canCheckpoint ? ( - Send at least one message to create checkpoints. - ) : checkpoints.length === 0 ? ( - - {isLoadingCheckpoints ? "Loading checkpoints..." : "No checkpoints available."} - - ) : ( - checkpoints.map((checkpoint) => ( - onRevertToCheckpoint(checkpoint)} - > - {checkpoint.label} - {checkpoint.preview ? `: ${checkpoint.preview}` : ""} - {checkpoint.isCurrent ? " (Current)" : ""} - - )) - )} - - @@ -2317,6 +2422,9 @@ interface MessagesTimelineProps { expandedWorkGroups: Record; onToggleWorkGroup: (groupId: string) => void; onOpenTurnDiff: (turnId: string, filePath?: string) => void; + revertTurnCountByUserMessageId: Map; + onRevertUserMessage: (messageId: string) => void; + isRevertingCheckpoint: boolean; onImageExpand: (image: ExpandedImagePreview) => void; messagesEndRef: RefObject; } @@ -2333,6 +2441,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ expandedWorkGroups, onToggleWorkGroup, onOpenTurnDiff, + revertTurnCountByUserMessageId, + onRevertUserMessage, + isRevertingCheckpoint, onImageExpand, messagesEndRef, }: MessagesTimelineProps) { @@ -2433,6 +2544,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ if (timelineEntry.message.role === "user") { const userImages = timelineEntry.message.attachments ?? []; + const canRevertAgentWork = revertTurnCountByUserMessageId.has(timelineEntry.message.id); return (
@@ -2467,9 +2579,22 @@ const MessagesTimeline = memo(function MessagesTimeline({ {timelineEntry.message.text} )} -

- {formatTimestamp(timelineEntry.message.createdAt)} -

+
+ {canRevertAgentWork && ( + + )} +

+ {formatTimestamp(timelineEntry.message.createdAt)} +

+
@@ -2497,11 +2622,45 @@ const MessagesTimeline = memo(function MessagesTimeline({ {(() => { const turnSummary = turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id); if (!turnSummary) return null; + const isCheckpointDiffLoading = + !turnSummary.checkpointDiffLoaded && turnSummary.files.length === 0; + const summaryStat = turnSummary.unifiedDiff + ? countDiffStat(turnSummary.unifiedDiff) + : turnSummary.files.reduce( + (acc, file) => { + const next = + typeof file.additions === "number" && typeof file.deletions === "number" + ? { additions: file.additions, deletions: file.deletions } + : file.diff + ? countDiffStat(file.diff) + : null; + if (!next) { + return acc; + } + return { + additions: acc.additions + next.additions, + deletions: acc.deletions + next.deletions, + }; + }, + { additions: 0, deletions: 0 }, + ); + const changedFileCountLabel = isCheckpointDiffLoading + ? "..." + : String(turnSummary.files.length); return (

- Changed files ({turnSummary.files.length}) + Changed files ({changedFileCountLabel}) + {!isCheckpointDiffLoading && + (summaryStat.additions > 0 || summaryStat.deletions > 0) && ( + <> + • + +{summaryStat.additions} + / + -{summaryStat.deletions} + + )}

-
- {turnSummary.files.map((file) => ( - - ))} -
+ {isCheckpointDiffLoading && ( +

+ Loading checkpoint diff... +

+ )} + {turnSummary.files.length > 0 ? ( +
+ {turnSummary.files.map((file) => ( + + ))} +
+ ) : !isCheckpointDiffLoading ? ( +

No changed files.

+ ) : null}
); })()} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 8cecdedd5d932b08009e59a8ecfe4090b73ebc8c..07014c6a3017657187ab43542b434180906c1e9a 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,62 +1,473 @@ -import { PatchDiff } from "@pierre/diffs/react"; -import { XIcon } from "lucide-react"; -import { useMemo } from "react"; +import { parsePatchFiles } from "@pierre/diffs"; +import { FileDiff, type FileDiffMetadata } from "@pierre/diffs/react"; +import { Columns2Icon, Rows3Icon, XIcon } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { isElectron } from "../env"; -import { deriveTurnDiffSummaries, formatTimestamp } from "../session-logic"; +import { useNativeApi } from "../hooks/useNativeApi"; +import { useTheme } from "../hooks/useTheme"; +import { + deriveTurnDiffSummaries, + formatTimestamp, + inferCheckpointTurnCountByTurnId, +} from "../session-logic"; import { useStore } from "../store"; import { Button } from "./ui/button"; +import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { cn } from "~/lib/utils"; -export default function DiffPanel() { +type DiffRenderMode = "stacked" | "split"; + +type RenderablePatch = + | { + kind: "files"; + files: FileDiffMetadata[]; + } + | { + kind: "raw"; + text: string; + reason: string; + }; + +function getRenderablePatch(patch: string | undefined): RenderablePatch | null { + if (!patch) return null; + const normalizedPatch = patch.trim(); + if (normalizedPatch.length === 0) return null; + + try { + const parsedPatches = parsePatchFiles(normalizedPatch, "diff-panel"); + const files = parsedPatches.flatMap((parsedPatch) => parsedPatch.files); + if (files.length > 0) { + return { kind: "files", files }; + } + + return { + kind: "raw", + text: normalizedPatch, + reason: "Unsupported diff format. Showing raw patch.", + }; + } catch { + return { + kind: "raw", + text: normalizedPatch, + reason: "Failed to parse patch. Showing raw patch.", + }; + } +} + +function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { + const raw = fileDiff.name ?? fileDiff.prevName ?? ""; + if (raw.startsWith("a/") || raw.startsWith("b/")) { + return raw.slice(2); + } + return raw; +} + +function buildCheckpointDiffKey( + sessionId: string, + fromTurnCount: number, + toTurnCount: number, +): string { + return `${sessionId}:${fromTurnCount}-${toTurnCount}`; +} + +interface DiffPanelProps { + mode?: "inline" | "sheet"; +} + +export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { + const api = useNativeApi(); + const { resolvedTheme } = useTheme(); const { state, dispatch } = useStore(); + const [checkpointDiffByKey, setCheckpointDiffByKey] = useState>({}); + const [isLoadingCheckpointDiff, setIsLoadingCheckpointDiff] = useState(false); + const [checkpointDiffError, setCheckpointDiffError] = useState(null); + const [diffRenderMode, setDiffRenderMode] = useState("stacked"); + const patchViewportRef = useRef(null); const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId); const turnDiffSummaries = useMemo( - () => deriveTurnDiffSummaries(activeThread?.events ?? []), - [activeThread?.events], + () => + activeThread?.turnDiffSummaries.length + ? activeThread.turnDiffSummaries + : deriveTurnDiffSummaries(activeThread?.events ?? []), + [activeThread?.events, activeThread?.turnDiffSummaries], ); + const inferredCheckpointTurnCountByTurnId = useMemo( + () => inferCheckpointTurnCountByTurnId(turnDiffSummaries), + [turnDiffSummaries], + ); + + useEffect(() => { + setCheckpointDiffByKey({}); + setCheckpointDiffError(null); + }, [activeThread?.id]); const canApplyStoredTarget = Boolean(activeThread && state.diffThreadId === activeThread.id); const selectedTurnId = canApplyStoredTarget ? state.diffTurnId : null; - const selectedFilePath = canApplyStoredTarget ? state.diffFilePath : null; + const selectedFilePath = + canApplyStoredTarget && selectedTurnId !== null ? state.diffFilePath : null; const selectedTurn = - turnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? turnDiffSummaries[0]; - const selectedFile = - selectedTurn?.files.find((file) => file.path === selectedFilePath) ?? selectedTurn?.files[0]; - const selectedPatch = selectedFile?.diff ?? selectedTurn?.unifiedDiff; + selectedTurnId === null + ? undefined + : (turnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? + turnDiffSummaries[0]); + const selectedCheckpointTurnCount = + selectedTurn && + (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]); + const selectedCheckpointRange = useMemo( + () => + typeof selectedCheckpointTurnCount === "number" + ? { + fromTurnCount: Math.max(0, selectedCheckpointTurnCount - 1), + toTurnCount: selectedCheckpointTurnCount, + } + : null, + [selectedCheckpointTurnCount], + ); + const conversationCheckpointTurnCount = useMemo(() => { + const turnCounts = turnDiffSummaries + .map( + (summary) => + summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId], + ) + .filter((value): value is number => typeof value === "number"); + if (turnCounts.length === 0) { + return undefined; + } + const latest = Math.max(...turnCounts); + return latest > 0 ? latest : undefined; + }, [inferredCheckpointTurnCountByTurnId, turnDiffSummaries]); + const conversationCheckpointRange = useMemo( + () => + !selectedTurn && typeof conversationCheckpointTurnCount === "number" + ? { + fromTurnCount: 0, + toTurnCount: conversationCheckpointTurnCount, + } + : null, + [conversationCheckpointTurnCount, selectedTurn], + ); + const selectedCheckpointDiffKey = + activeThread?.session?.sessionId && selectedCheckpointRange + ? buildCheckpointDiffKey( + activeThread.session.sessionId, + selectedCheckpointRange.fromTurnCount, + selectedCheckpointRange.toTurnCount, + ) + : null; + const conversationCheckpointDiffKey = + activeThread?.session?.sessionId && conversationCheckpointRange + ? buildCheckpointDiffKey( + activeThread.session.sessionId, + conversationCheckpointRange.fromTurnCount, + conversationCheckpointRange.toTurnCount, + ) + : null; + const selectedTurnCheckpointDiff = + selectedCheckpointDiffKey === null ? undefined : checkpointDiffByKey[selectedCheckpointDiffKey]; + const conversationCheckpointDiff = + conversationCheckpointDiffKey === null + ? undefined + : checkpointDiffByKey[conversationCheckpointDiffKey]; + const selectedPatch = useMemo(() => { + const patchForSummary = (summary: (typeof turnDiffSummaries)[number]): string | undefined => { + const checkpointTurnCount = + summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; + if (activeThread?.session?.sessionId && typeof checkpointTurnCount === "number") { + const checkpointPatch = + checkpointDiffByKey[ + buildCheckpointDiffKey( + activeThread.session.sessionId, + Math.max(0, checkpointTurnCount - 1), + checkpointTurnCount, + ) + ]; + if (checkpointPatch) { + return checkpointPatch; + } + } + if (summary.unifiedDiff) { + return summary.unifiedDiff; + } + const filePatches = summary.files + .map((file) => file.diff?.trim()) + .filter((patch): patch is string => Boolean(patch)); + if (filePatches.length === 0) { + return undefined; + } + return filePatches.join("\n\n"); + }; + + if (selectedTurn) { + return selectedTurnCheckpointDiff ?? patchForSummary(selectedTurn); + } + + if (conversationCheckpointDiff) { + return conversationCheckpointDiff; + } + + // Fallback when a conversation checkpoint diff isn't available yet: + // keep one patch per file path (latest change wins) so files aren't duplicated. + const latestPatchByPath = new Map(); + for (const summary of turnDiffSummaries) { + for (const file of summary.files) { + if (latestPatchByPath.has(file.path)) { + continue; + } + const patch = file.diff?.trim(); + if (!patch) { + continue; + } + latestPatchByPath.set(file.path, patch); + } + } + if (latestPatchByPath.size > 0) { + return Array.from(latestPatchByPath.entries()) + .toSorted(([leftPath], [rightPath]) => + leftPath.localeCompare(rightPath, undefined, { numeric: true, sensitivity: "base" }), + ) + .map(([, patch]) => patch) + .join("\n\n"); + } + + const patches = turnDiffSummaries + .toReversed() + .map((summary) => patchForSummary(summary)?.trim()) + .filter((patch): patch is string => Boolean(patch)); + if (patches.length === 0) { + return undefined; + } + return patches.join("\n\n"); + }, [ + activeThread?.session?.sessionId, + checkpointDiffByKey, + conversationCheckpointDiff, + inferredCheckpointTurnCountByTurnId, + selectedTurn, + selectedTurnCheckpointDiff, + turnDiffSummaries, + ]); + const renderablePatch = useMemo(() => getRenderablePatch(selectedPatch), [selectedPatch]); + const renderableFiles = useMemo(() => { + if (!renderablePatch || renderablePatch.kind !== "files") { + return []; + } + return renderablePatch.files.toSorted((left, right) => + resolveFileDiffPath(left).localeCompare(resolveFileDiffPath(right), undefined, { + numeric: true, + sensitivity: "base", + }), + ); + }, [renderablePatch]); + + useEffect(() => { + const checkpointFetchRange = selectedTurn + ? selectedCheckpointRange + : conversationCheckpointRange; + const checkpointFetchKey = selectedTurn + ? selectedCheckpointDiffKey + : conversationCheckpointDiffKey; + if (!api || !activeThread?.session?.sessionId || !checkpointFetchRange || !checkpointFetchKey) { + return; + } + if (checkpointDiffByKey[checkpointFetchKey] !== undefined) { + return; + } + + let cancelled = false; + setIsLoadingCheckpointDiff(true); + setCheckpointDiffError(null); + void api.providers + .getCheckpointDiff({ + sessionId: activeThread.session.sessionId, + fromTurnCount: checkpointFetchRange.fromTurnCount, + toTurnCount: checkpointFetchRange.toTurnCount, + }) + .then((result) => { + if (cancelled) return; + setCheckpointDiffByKey((previous) => ({ + ...previous, + [checkpointFetchKey]: result.diff, + })); + if (selectedTurn) { + dispatch({ + type: "SET_THREAD_TURN_CHECKPOINT_DIFFS", + threadId: activeThread.id, + checkpointDiffByTurnId: { + [selectedTurn.turnId]: result.diff, + }, + }); + } + }) + .catch((error) => { + if (cancelled) return; + setCheckpointDiffError( + error instanceof Error ? error.message : "Failed to load checkpoint diff.", + ); + }) + .finally(() => { + if (cancelled) return; + setIsLoadingCheckpointDiff(false); + }); + + return () => { + cancelled = true; + }; + }, [ + activeThread?.id, + activeThread?.session?.sessionId, + api, + checkpointDiffByKey, + conversationCheckpointDiffKey, + conversationCheckpointRange, + dispatch, + selectedCheckpointDiffKey, + selectedCheckpointRange, + selectedTurn, + ]); + + useEffect(() => { + if (!selectedFilePath || !patchViewportRef.current) { + return; + } + const target = Array.from( + patchViewportRef.current.querySelectorAll("[data-diff-file-path]"), + ).find((element) => element.dataset.diffFilePath === selectedFilePath); + target?.scrollIntoView({ block: "nearest" }); + }, [selectedFilePath, renderableFiles]); const selectTurn = (turnId: string) => { if (!activeThread) return; - const turn = turnDiffSummaries.find((summary) => summary.turnId === turnId); dispatch({ type: "SET_DIFF_TARGET", threadId: activeThread.id, turnId, - ...(turn?.files[0]?.path ? { filePath: turn.files[0].path } : {}), }); }; - - const selectFile = (filePath: string) => { - if (!activeThread || !selectedTurn) return; + const selectWholeConversation = () => { + if (!activeThread) return; dispatch({ type: "SET_DIFF_TARGET", threadId: activeThread.id, - turnId: selectedTurn.turnId, - filePath, }); }; + const shouldUseDragRegion = isElectron && mode === "inline"; + return ( -