From 351736baa119e8a9e71f1811edf37ef0615a0541 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 17 Feb 2026 10:39:23 -0800 Subject: [PATCH] Add provider checkpoints and thread rollback support - add provider/WS contracts and server RPC handlers for listing/reverting checkpoints - read and rollback Codex thread turns in the app server manager - add ChatView checkpoint menu + store reducer support to revert local thread state - expand backend, websocket, store, and contract tests for checkpoint flows --- apps/server/src/codexAppServerManager.test.ts | 89 +++++++++ apps/server/src/codexAppServerManager.ts | 80 ++++++++ apps/server/src/providerManager.test.ts | 132 +++++++++++++ apps/server/src/providerManager.ts | 182 ++++++++++++++++++ apps/server/src/wsServer.test.ts | 24 +++ apps/server/src/wsServer.ts | 6 + apps/web/src/components/ChatView.tsx | 150 ++++++++++++++- apps/web/src/store.test.ts | 62 ++++++ apps/web/src/store.ts | 39 ++++ apps/web/src/wsNativeApi.ts | 3 + packages/contracts/src/ipc.ts | 8 + packages/contracts/src/provider.test.ts | 73 +++++++ packages/contracts/src/provider.ts | 38 ++++ packages/contracts/src/ws.ts | 2 + 14 files changed, 887 insertions(+), 1 deletion(-) diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index 4a97814a73080a72532e2204dfc5c306a2be8ee5..e1ce2fcd1244c97a607e7c4873d1bee54bf3fc64 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -43,6 +43,36 @@ function createSendTurnHarness() { return { manager, context, requireSession, sendRequest, updateSession }; } +function createThreadControlHarness() { + const manager = new CodexAppServerManager(); + const context = { + session: { + sessionId: "sess_1", + provider: "codex", + status: "ready", + threadId: "thread_1", + createdAt: "2026-02-10T00:00:00.000Z", + updatedAt: "2026-02-10T00:00:00.000Z", + }, + }; + + const requireSession = vi + .spyOn( + manager as unknown as { requireSession: (sessionId: string) => unknown }, + "requireSession", + ) + .mockReturnValue(context); + const sendRequest = vi.spyOn( + manager as unknown as { sendRequest: (...args: unknown[]) => Promise }, + "sendRequest", + ); + const updateSession = vi + .spyOn(manager as unknown as { updateSession: (...args: unknown[]) => void }, "updateSession") + .mockImplementation(() => {}); + + return { manager, context, requireSession, sendRequest, updateSession }; +} + describe("classifyCodexStderrLine", () => { it("ignores empty lines", () => { expect(classifyCodexStderrLine(" ")).toBeNull(); @@ -198,3 +228,62 @@ describe("sendTurn", () => { ).rejects.toThrow("Turn input must include text or attachments."); }); }); + +describe("thread checkpoint control", () => { + it("reads thread turns from thread/read", async () => { + const { manager, context, requireSession, sendRequest } = createThreadControlHarness(); + sendRequest.mockResolvedValue({ + thread: { + id: "thread_1", + turns: [ + { + id: "turn_1", + items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], + }, + ], + }, + }); + + const result = await manager.readThread("sess_1"); + + expect(requireSession).toHaveBeenCalledWith("sess_1"); + expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", { + threadId: "thread_1", + includeTurns: true, + }); + expect(result).toEqual({ + threadId: "thread_1", + turns: [ + { + id: "turn_1", + items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], + }, + ], + }); + }); + + it("rolls back turns via thread/rollback and resets session running state", async () => { + const { manager, context, sendRequest, updateSession } = createThreadControlHarness(); + sendRequest.mockResolvedValue({ + thread: { + id: "thread_1", + turns: [], + }, + }); + + const result = await manager.rollbackThread("sess_1", 2); + + expect(sendRequest).toHaveBeenCalledWith(context, "thread/rollback", { + threadId: "thread_1", + numTurns: 2, + }); + expect(updateSession).toHaveBeenCalledWith(context, { + status: "ready", + activeTurnId: undefined, + }); + expect(result).toEqual({ + threadId: "thread_1", + turns: [], + }); + }); +}); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 27da26fb0bfdf8a522ab138746a2513d54a9eab4..01722a69667c6df8e684ddfbd72a39b59557597a 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -65,6 +65,16 @@ interface JsonRpcNotification { params?: unknown; } +export interface CodexThreadTurnSnapshot { + id: string; + items: unknown[]; +} + +export interface CodexThreadSnapshot { + threadId: string; + turns: CodexThreadTurnSnapshot[]; +} + const ANSI_ESCAPE_CHAR = String.fromCharCode(27); const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g"); const CODEX_STDERR_LOG_REGEX = @@ -331,6 +341,41 @@ export class CodexAppServerManager extends EventEmitter { + const context = this.requireSession(sessionId); + const threadId = context.session.threadId; + if (!threadId) { + throw new Error("Session is missing a thread id."); + } + + const response = await this.sendRequest(context, "thread/read", { + threadId, + includeTurns: true, + }); + return this.parseThreadSnapshot("thread/read", response); + } + + async rollbackThread(sessionId: string, numTurns: number): Promise { + const context = this.requireSession(sessionId); + const threadId = context.session.threadId; + if (!threadId) { + throw new Error("Session is missing a thread id."); + } + if (!Number.isInteger(numTurns) || numTurns < 1) { + throw new Error("numTurns must be an integer >= 1."); + } + + const response = await this.sendRequest(context, "thread/rollback", { + threadId, + numTurns, + }); + this.updateSession(context, { + status: "ready", + activeTurnId: undefined, + }); + return this.parseThreadSnapshot("thread/rollback", response); + } + async respondToRequest( sessionId: string, requestId: string, @@ -742,6 +787,31 @@ export class CodexAppServerManager extends EventEmitter { + const turn = this.readObject(turnValue); + const turnId = this.readString(turn, "id") ?? `${threadId}:turn:${index + 1}`; + const items = this.readArray(turn, "items") ?? []; + return { + id: turnId, + items, + }; + }); + + return { + threadId, + turns, + }; + } + private isServerRequest(value: unknown): value is JsonRpcRequest { if (!value || typeof value !== "object") { return false; @@ -823,6 +893,16 @@ export class CodexAppServerManager extends EventEmitter; } + private readArray(value: unknown, key?: string): unknown[] | undefined { + const target = + key === undefined + ? value + : value && typeof value === "object" + ? (value as Record)[key] + : undefined; + return Array.isArray(target) ? target : undefined; + } + private readString(value: unknown, key: string): string | undefined { if (!value || typeof value !== "object") { return undefined; diff --git a/apps/server/src/providerManager.test.ts b/apps/server/src/providerManager.test.ts index 424cfce16c0f9ab13c038f9b3fa0f78bbc4f7dc5..7d7e7ad76fdef7321ecabfbda7a715bed79b6e1d 100644 --- a/apps/server/src/providerManager.test.ts +++ b/apps/server/src/providerManager.test.ts @@ -59,4 +59,136 @@ describe("ProviderManager", () => { manager.dispose(); }); + + it("rejects checkpoint operations for unknown sessions", async () => { + const manager = new ProviderManager(); + + await expect( + manager.listCheckpoints({ + sessionId: "missing-session", + }), + ).rejects.toThrow("Unknown provider session: missing-session"); + + await expect( + manager.revertToCheckpoint({ + sessionId: "missing-session", + turnCount: 0, + }), + ).rejects.toThrow("Unknown provider session: missing-session"); + + manager.dispose(); + }); + + it("derives checkpoints from thread turns", async () => { + const manager = new ProviderManager(); + const codex = ( + manager as unknown as { + codex: { + hasSession: (sessionId: string) => boolean; + readThread: (sessionId: string) => Promise<{ + threadId: string; + turns: Array<{ id: string; items: unknown[] }>; + }>; + }; + } + ).codex; + + codex.hasSession = () => true; + codex.readThread = async () => ({ + threadId: "thr_1", + turns: [ + { + id: "turn_1", + items: [ + { + type: "userMessage", + content: [{ type: "text", text: "Refactor the logger" }], + }, + { + type: "agentMessage", + text: "I refactored it.", + }, + ], + }, + ], + }); + + const result = await manager.listCheckpoints({ + sessionId: "sess_1", + }); + + expect(result).toEqual({ + threadId: "thr_1", + checkpoints: [ + { + id: "root", + turnCount: 0, + messageCount: 0, + label: "Start of conversation", + isCurrent: false, + }, + { + id: "turn_1", + turnCount: 1, + messageCount: 2, + label: "Turn 1", + preview: "Refactor the logger", + isCurrent: true, + }, + ], + }); + + manager.dispose(); + }); + + it("rolls back from a selected checkpoint turn count", async () => { + const manager = new ProviderManager(); + const codex = ( + 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[] }>; + }>; + }; + } + ).codex; + + const rollbackCalls: Array<{ sessionId: string; numTurns: number }> = []; + codex.hasSession = () => true; + codex.readThread = async () => ({ + threadId: "thr_1", + turns: [ + { id: "turn_1", items: [] }, + { id: "turn_2", items: [] }, + { id: "turn_3", items: [] }, + ], + }); + codex.rollbackThread = async (sessionId, numTurns) => { + rollbackCalls.push({ sessionId, numTurns }); + return { + threadId: "thr_1", + turns: [{ id: "turn_1", items: [] }], + }; + }; + + const result = await manager.revertToCheckpoint({ + sessionId: "sess_1", + turnCount: 1, + }); + + expect(rollbackCalls).toEqual([{ sessionId: "sess_1", numTurns: 2 }]); + expect(result.threadId).toBe("thr_1"); + expect(result.turnCount).toBe(1); + expect(result.messageCount).toBe(0); + expect(result.rolledBackTurns).toBe(2); + expect(result.checkpoints).toHaveLength(2); + + manager.dispose(); + }); }); diff --git a/apps/server/src/providerManager.ts b/apps/server/src/providerManager.ts index 52fb1b648475846cdc7448108419bab9a3c3418e..58977b29b9e73bbbd4fcc0ccf0e6e4b75a75b79c 100644 --- a/apps/server/src/providerManager.ts +++ b/apps/server/src/providerManager.ts @@ -3,8 +3,13 @@ import fs from "node:fs"; import path from "node:path"; import { + type ProviderCheckpoint, type ProviderEvent, type ProviderInterruptTurnInput, + type ProviderListCheckpointsInput, + type ProviderListCheckpointsResult, + type ProviderRevertToCheckpointInput, + type ProviderRevertToCheckpointResult, type ProviderRespondToRequestInput, type ProviderSendTurnInput, type ProviderSession, @@ -12,17 +17,144 @@ import { type ProviderStopSessionInput, type ProviderTurnStartResult, providerInterruptTurnInputSchema, + providerListCheckpointsInputSchema, + providerRevertToCheckpointInputSchema, providerRespondToRequestInputSchema, providerSendTurnInputSchema, providerSessionStartInputSchema, providerStopSessionInputSchema, } from "@t3tools/contracts"; +import type { CodexThreadTurnSnapshot } from "./codexAppServerManager"; import { CodexAppServerManager } from "./codexAppServerManager"; export interface ProviderManagerEvents { event: [event: ProviderEvent]; } +function asObject(value: unknown): Record | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + return value as Record; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function trimToPreview(value: string): string { + const normalized = value.replace(/\s+/g, " ").trim(); + if (normalized.length <= 120) { + return normalized; + } + return `${normalized.slice(0, 117)}...`; +} + +function summarizeUserMessageContent(content: unknown[]): string | undefined { + const segments: string[] = []; + + for (const part of content) { + const record = asObject(part); + const type = asString(record?.type); + if (!type) continue; + + if (type === "text") { + const text = asString(record?.text); + if (text && text.trim().length > 0) { + segments.push(text.trim()); + } + continue; + } + + if (type === "image") { + segments.push("[Image attachment]"); + continue; + } + + if (type === "localImage") { + segments.push("[Local image attachment]"); + continue; + } + } + + if (segments.length === 0) { + return undefined; + } + return trimToPreview(segments.join(" ")); +} + +function summarizeTurn(turn: CodexThreadTurnSnapshot): { + messageCountDelta: number; + preview?: string; +} { + let messageCountDelta = 0; + let preview: string | undefined; + + for (const item of turn.items) { + const record = asObject(item); + const type = asString(record?.type); + if (!type) continue; + + if (type === "userMessage") { + messageCountDelta += 1; + if (!preview) { + const content = asArray(record?.content); + preview = summarizeUserMessageContent(content); + } + continue; + } + + if (type === "agentMessage") { + messageCountDelta += 1; + if (!preview) { + const text = asString(record?.text); + if (text && text.trim().length > 0) { + preview = trimToPreview(text); + } + } + } + } + + return { + messageCountDelta, + ...(preview ? { preview } : {}), + }; +} + +function buildCheckpoints(turns: CodexThreadTurnSnapshot[]): ProviderCheckpoint[] { + const checkpoints: ProviderCheckpoint[] = []; + let messageCount = 0; + const isEmpty = turns.length === 0; + checkpoints.push({ + id: "root", + turnCount: 0, + messageCount: 0, + label: "Start of conversation", + isCurrent: isEmpty, + }); + + for (let index = 0; index < turns.length; index += 1) { + const turn = turns[index]; + if (!turn) continue; + const turnSummary = summarizeTurn(turn); + messageCount += turnSummary.messageCountDelta; + checkpoints.push({ + id: turn.id, + turnCount: index + 1, + messageCount, + label: `Turn ${index + 1}`, + ...(turnSummary.preview ? { preview: turnSummary.preview } : {}), + isCurrent: index === turns.length - 1, + }); + } + + return checkpoints; +} + export class ProviderManager extends EventEmitter { private readonly codex = new CodexAppServerManager(); private readonly threadLogsDir: string; @@ -101,6 +233,56 @@ export class ProviderManager extends EventEmitter { return this.codex.listSessions(); } + async listCheckpoints(raw: ProviderListCheckpointsInput): Promise { + const input = providerListCheckpointsInputSchema.parse(raw); + if (!this.codex.hasSession(input.sessionId)) { + throw new Error(`Unknown provider session: ${input.sessionId}`); + } + + const snapshot = await this.codex.readThread(input.sessionId); + return { + threadId: snapshot.threadId, + checkpoints: buildCheckpoints(snapshot.turns), + }; + } + + async revertToCheckpoint( + raw: ProviderRevertToCheckpointInput, + ): Promise { + const input = providerRevertToCheckpointInputSchema.parse(raw); + if (!this.codex.hasSession(input.sessionId)) { + throw new Error(`Unknown provider session: ${input.sessionId}`); + } + + const beforeSnapshot = await this.codex.readThread(input.sessionId); + const currentTurnCount = beforeSnapshot.turns.length; + if (input.turnCount > currentTurnCount) { + throw new Error( + `Checkpoint turn count ${input.turnCount} exceeds current turn count ${currentTurnCount}.`, + ); + } + + const requestedRollbackTurns = currentTurnCount - input.turnCount; + const afterSnapshot = + requestedRollbackTurns > 0 + ? await this.codex.rollbackThread(input.sessionId, requestedRollbackTurns) + : beforeSnapshot; + const checkpoints = buildCheckpoints(afterSnapshot.turns); + const currentCheckpoint = + checkpoints.find((checkpoint) => checkpoint.isCurrent) ?? + checkpoints[checkpoints.length - 1] ?? + checkpoints[0]; + const rolledBackTurns = Math.max(0, currentTurnCount - afterSnapshot.turns.length); + + return { + threadId: afterSnapshot.threadId, + turnCount: currentCheckpoint?.turnCount ?? 0, + messageCount: currentCheckpoint?.messageCount ?? 0, + rolledBackTurns, + checkpoints, + }; + } + stopAll(): void { this.codex.stopAll(); } diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 635bfbb81291c39a8240ae27b7e8277e5ee7863e..a860c5d8ae20143b1d1eeff0c36e7b60a1bf167d 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -646,6 +646,30 @@ describe("WebSocket Server", () => { expect(response.result).toEqual([]); }); + it("returns unknown-session errors for checkpoint RPCs without an active provider session", async () => { + server = createTestServer({ cwd: "/test" }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const listResponse = await sendRequest(ws, WS_METHODS.providersListCheckpoints, { + sessionId: "missing-session", + }); + expect(listResponse.result).toBeUndefined(); + expect(listResponse.error?.message).toContain("Unknown provider session"); + + const revertResponse = await sendRequest(ws, WS_METHODS.providersRevertToCheckpoint, { + sessionId: "missing-session", + turnCount: 0, + }); + expect(revertResponse.result).toBeUndefined(); + expect(revertResponse.error?.message).toContain("Unknown provider session"); + }); + it("routes terminal RPC methods and broadcasts terminal events", async () => { const cwd = makeTempDir("t3code-ws-terminal-cwd-"); const terminalManager = new MockTerminalManager(); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index ce9878cd469bb982e0ba6a706f8a3d98b514fa73..ec0d73d59e3b7652c37020559f312a5219758aa8 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -310,6 +310,12 @@ export function createServer(options: ServerOptions) { case WS_METHODS.providersListSessions: return providerManager.listSessions(); + case WS_METHODS.providersListCheckpoints: + return providerManager.listCheckpoints(request.params as never); + + case WS_METHODS.providersRevertToCheckpoint: + return providerManager.revertToCheckpoint(request.params as never); + case WS_METHODS.projectsList: return projectRegistry.list(); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 27904e29e5ba563f60fc0295c6c2f307bf62ecb9..2fdccc071529599b4ff4795f96695f021c7cee52 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8,6 +8,7 @@ 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, @@ -345,6 +346,9 @@ 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); @@ -374,7 +378,7 @@ export default function ChatView() { activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, ); const phase = derivePhase(activeThread?.session ?? null); - const isWorking = phase === "running" || isSending || isConnecting; + const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint; const nowIso = new Date(nowTick).toISOString(); const workLogEntries = useMemo( () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), @@ -575,6 +579,12 @@ 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) { @@ -963,6 +973,12 @@ export default function ChatView() { ); }, [composerMenuItems, composerMenuOpen]); + useEffect(() => { + setCheckpoints([]); + setIsLoadingCheckpoints(false); + setIsRevertingCheckpoint(false); + }, [activeThread?.id]); + useEffect(() => { if (!activeThread?.id || activeThread.terminalOpen) return; const frame = window.requestAnimationFrame(() => { @@ -1311,6 +1327,78 @@ export default function ChatView() { } }; + 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) { + return null; + } + 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); + } + }; + + 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."); + 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: 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); + } + }; + const onSend = async (e: FormEvent) => { e.preventDefault(); if (!api || !activeThread || isSending || isConnecting) return; @@ -1641,6 +1729,14 @@ export default function ChatView() { onAddProjectScript={saveProjectScript} onUpdateProjectScript={updateProjectScript} onToggleDiff={onToggleDiff} + checkpoints={checkpoints} + canCheckpoint={canCheckpoint} + isLoadingCheckpoints={isLoadingCheckpoints} + isRevertingCheckpoint={isRevertingCheckpoint} + onOpenCheckpoints={() => { + void loadCheckpoints(); + }} + onRevertToCheckpoint={onRevertToCheckpoint} /> @@ -1974,6 +2070,12 @@ 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({ @@ -1989,6 +2091,12 @@ const ChatHeader = memo(function ChatHeader({ onAddProjectScript, onUpdateProjectScript, onToggleDiff, + checkpoints, + canCheckpoint, + isLoadingCheckpoints, + isRevertingCheckpoint, + onOpenCheckpoints, + onRevertToCheckpoint, }: ChatHeaderProps) { return ( <> @@ -2011,6 +2119,46 @@ 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)" : ""} + + )) + )} + +