diff --git a/e2e-tests/restore_to_message.spec.ts b/e2e-tests/restore_to_message.spec.ts new file mode 100644 index 0000000000..3e5dd49a9e --- /dev/null +++ b/e2e-tests/restore_to_message.spec.ts @@ -0,0 +1,186 @@ +import { testSkipIfWindows, Timeout } from "./helpers/test_helper"; +import { expect } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +/** + * E2E test for the per-message "restore" arrow on user messages. + * + * Clicking the arrow on a user message should: + * 1. Create a NEW chat containing only the messages before that message + * (the original chat stays intact). + * 2. Restore the app's code to the version that existed right before that + * message was sent. + * 3. Navigate the user to the new chat. + */ +testSkipIfWindows( + "restore to message - forks chat and reverts code", + async ({ po }) => { + await po.setUp({ autoApprove: true }); + await po.importApp("minimal"); + + const indexPath = async () => + path.join( + await po.appManagement.getCurrentAppPath(), + "src", + "pages", + "Index.tsx", + ); + + // Turn A: writes src/pages/Index.tsx -> creates a version. + await po.sendPrompt("tc=write-index"); + expect(fs.readFileSync(await indexPath(), "utf-8")).toContain( + "Testing:write-index!", + ); + + // Turn B: overwrites src/pages/Index.tsx -> creates a newer version. + await po.sendPrompt("tc=write-index-2"); + expect(fs.readFileSync(await indexPath(), "utf-8")).toContain( + "Testing:write-index(2)!", + ); + + const originalChatId = po.page.url().match(/[?&]id=(\d+)/)?.[1]; + expect(originalChatId).toBeTruthy(); + + // Importing the "minimal" fixture triggers an auto-generated AI_RULES.md + // user message, so the chat has three user messages total: AI_RULES, turn + // A, turn B. Each gets a restore button. + const restoreButtons = po.page.getByTestId("restore-to-message-button"); + await expect(restoreButtons).toHaveCount(3); + + // Click the undo icon on the LAST user message (turn B), then confirm in + // the dialog. This should create a new chat with [AI_RULES, userA, + // assistantA] and revert the app to the state after turn A (i.e. before + // turn B). + await restoreButtons.nth(2).click(); + await po.page.getByTestId("confirm-restore-to-message-button").click(); + + // We should navigate to a brand-new chat. + await expect(async () => { + const newChatId = po.page.url().match(/[?&]id=(\d+)/)?.[1]; + expect(newChatId).toBeTruthy(); + expect(newChatId).not.toBe(originalChatId); + }).toPass({ timeout: Timeout.LONG }); + + // The new chat contains only the messages before turn B: the AI_RULES + // user message and turn A, so two restore buttons. + await expect(restoreButtons).toHaveCount(2); + + const messagesList = po.page.getByTestId("messages-list"); + await expect(messagesList).toContainText("tc=write-index"); + await expect(messagesList).not.toContainText("tc=write-index-2"); + + // The app code is reverted to the state right before turn B. + await expect(async () => { + const content = fs.readFileSync(await indexPath(), "utf-8"); + expect(content).toContain("Testing:write-index!"); + expect(content).not.toContain("Testing:write-index(2)!"); + }).toPass({ timeout: Timeout.LONG }); + + // The original chat must be left intact: the confirmation dialog promises + // "Your current chat will not be changed". Navigate back to it and verify + // both turns are still present. + await po.page.getByRole("link", { name: "Apps" }).hover(); + await expect(po.page.getByTestId("chat-list-container")).toBeVisible({ + timeout: Timeout.MEDIUM, + }); + await po.page.getByTestId(`chat-list-item-${originalChatId}`).click(); + await expect(async () => { + const currentChatId = po.page.url().match(/[?&]id=(\d+)/)?.[1]; + expect(currentChatId).toBe(originalChatId); + }).toPass({ timeout: Timeout.MEDIUM }); + await expect(messagesList).toContainText("tc=write-index"); + await expect(messagesList).toContainText("tc=write-index-2"); + }, +); + +/** + * "Fork chat only" forks the conversation into a new chat but leaves the app's + * code untouched. + */ +testSkipIfWindows( + "restore to message - fork chat only leaves code untouched", + async ({ po }) => { + await po.setUp({ autoApprove: true }); + await po.importApp("minimal"); + + const indexPath = async () => + path.join( + await po.appManagement.getCurrentAppPath(), + "src", + "pages", + "Index.tsx", + ); + + await po.sendPrompt("tc=write-index"); + await po.sendPrompt("tc=write-index-2"); + expect(fs.readFileSync(await indexPath(), "utf-8")).toContain( + "Testing:write-index(2)!", + ); + + const originalChatId = po.page.url().match(/[?&]id=(\d+)/)?.[1]; + expect(originalChatId).toBeTruthy(); + + const restoreButtons = po.page.getByTestId("restore-to-message-button"); + await expect(restoreButtons).toHaveCount(3); + + // Open the dialog on the last user message (turn B) and choose to only fork + // the chat. + await restoreButtons.nth(2).click(); + await po.page.getByTestId("fork-chat-button").click(); + + // We should navigate to a brand-new chat with only the messages before + // turn B. + await expect(async () => { + const newChatId = po.page.url().match(/[?&]id=(\d+)/)?.[1]; + expect(newChatId).toBeTruthy(); + expect(newChatId).not.toBe(originalChatId); + }).toPass({ timeout: Timeout.LONG }); + await expect(restoreButtons).toHaveCount(2); + + const messagesList = po.page.getByTestId("messages-list"); + await expect(messagesList).toContainText("tc=write-index"); + await expect(messagesList).not.toContainText("tc=write-index-2"); + + // The app code must NOT be reverted: forking only touches the chat. + expect(fs.readFileSync(await indexPath(), "utf-8")).toContain( + "Testing:write-index(2)!", + ); + }, +); + +/** + * Restoring to the very first message is allowed: it restores to "version 1" + * (the commit before the first message's changes) and forks a new, empty chat. + */ +testSkipIfWindows( + "restore to message - first message forks an empty chat", + async ({ po }) => { + await po.setUp({ autoApprove: true }); + await po.importApp("minimal"); + + await po.sendPrompt("tc=write-index"); + + const originalChatId = po.page.url().match(/[?&]id=(\d+)/)?.[1]; + expect(originalChatId).toBeTruthy(); + + // The imported "minimal" fixture adds an auto-generated AI_RULES.md user + // message followed by turn A, so there are two restore buttons. + const restoreButtons = po.page.getByTestId("restore-to-message-button"); + await expect(restoreButtons).toHaveCount(2); + + // Restore to the FIRST user message. Previously this failed with "Cannot + // restore before the first message"; now it forks an empty chat. + await restoreButtons.nth(0).click(); + await po.page.getByTestId("confirm-restore-to-message-button").click(); + + // We navigate to a brand-new chat with no prior messages, so no restore + // buttons are shown. + await expect(async () => { + const newChatId = po.page.url().match(/[?&]id=(\d+)/)?.[1]; + expect(newChatId).toBeTruthy(); + expect(newChatId).not.toBe(originalChatId); + }).toPass({ timeout: Timeout.LONG }); + await expect(restoreButtons).toHaveCount(0); + }, +); diff --git a/e2e-tests/snapshots/approve.spec.ts_write-to-index-approve-check-preview-1.aria.yml b/e2e-tests/snapshots/approve.spec.ts_write-to-index-approve-check-preview-1.aria.yml index 310f7c3a74..8cffadb8e4 100644 --- a/e2e-tests/snapshots/approve.spec.ts_write-to-index-approve-check-preview-1.aria.yml +++ b/e2e-tests/snapshots/approve.spec.ts_write-to-index-approve-check-preview-1.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: tc=write-index - paragraph: OK, I'm going to do some writing now... - 'button "Index.tsx src/pages/Index.tsx Edit Summary: write-description"' diff --git a/e2e-tests/snapshots/approve.spec.ts_write-to-index-approve-check-preview-2.aria.yml b/e2e-tests/snapshots/approve.spec.ts_write-to-index-approve-check-preview-2.aria.yml index d22f1a296b..849b974f52 100644 --- a/e2e-tests/snapshots/approve.spec.ts_write-to-index-approve-check-preview-2.aria.yml +++ b/e2e-tests/snapshots/approve.spec.ts_write-to-index-approve-check-preview-2.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: tc=write-index - paragraph: OK, I'm going to do some writing now... - 'button "Index.tsx src/pages/Index.tsx Edit Summary: write-description"' diff --git a/e2e-tests/snapshots/attach_image.spec.ts_attach-image---chat---upload-to-codebase-1.aria.yml b/e2e-tests/snapshots/attach_image.spec.ts_attach-image---chat---upload-to-codebase-1.aria.yml index d0e04f30cd..84881a3769 100644 --- a/e2e-tests/snapshots/attach_image.spec.ts_attach-image---chat---upload-to-codebase-1.aria.yml +++ b/e2e-tests/snapshots/attach_image.spec.ts_attach-image---chat---upload-to-codebase-1.aria.yml @@ -1,8 +1,10 @@ +- button "Restore to this point" - paragraph: basic - button "file1.txt file1.txt Edit" - paragraph: More EOM - button "Copy" - text: "[[Version 2: files changed]]" +- button "Restore to this point" - paragraph: "[[UPLOAD_IMAGE_TO_CODEBASE]]" - 'button "Expand image: logo.png"' - paragraph: Uploading image to codebase diff --git a/e2e-tests/snapshots/attach_image.spec.ts_attach-image---chat-1.aria.yml b/e2e-tests/snapshots/attach_image.spec.ts_attach-image---chat-1.aria.yml index f8390ba61a..d346c22793 100644 --- a/e2e-tests/snapshots/attach_image.spec.ts_attach-image---chat-1.aria.yml +++ b/e2e-tests/snapshots/attach_image.spec.ts_attach-image---chat-1.aria.yml @@ -1,8 +1,10 @@ +- button "Restore to this point" - paragraph: basic - button "file1.txt file1.txt Edit" - paragraph: More EOM - button "Copy" - text: "[[Version 2: files changed]]" +- button "Restore to this point" - paragraph: "[dump]" - 'button "Expand image: logo.png"' - paragraph: "[[dyad-dump-path=*]]" diff --git a/e2e-tests/snapshots/attach_image.spec.ts_attach-image---home-chat-1.aria.yml b/e2e-tests/snapshots/attach_image.spec.ts_attach-image---home-chat-1.aria.yml index 63e397577a..609caa8075 100644 --- a/e2e-tests/snapshots/attach_image.spec.ts_attach-image---home-chat-1.aria.yml +++ b/e2e-tests/snapshots/attach_image.spec.ts_attach-image---home-chat-1.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: "[dump]" - 'button "Expand image: logo.png"' - paragraph: "[[dyad-dump-path=*]]" diff --git a/e2e-tests/snapshots/attach_image.spec.ts_attach-image-via-drag---chat-1.aria.yml b/e2e-tests/snapshots/attach_image.spec.ts_attach-image-via-drag---chat-1.aria.yml index f8390ba61a..d346c22793 100644 --- a/e2e-tests/snapshots/attach_image.spec.ts_attach-image-via-drag---chat-1.aria.yml +++ b/e2e-tests/snapshots/attach_image.spec.ts_attach-image-via-drag---chat-1.aria.yml @@ -1,8 +1,10 @@ +- button "Restore to this point" - paragraph: basic - button "file1.txt file1.txt Edit" - paragraph: More EOM - button "Copy" - text: "[[Version 2: files changed]]" +- button "Restore to this point" - paragraph: "[dump]" - 'button "Expand image: logo.png"' - paragraph: "[[dyad-dump-path=*]]" diff --git a/e2e-tests/snapshots/auto_approve.spec.ts_auto-approve-1.aria.yml b/e2e-tests/snapshots/auto_approve.spec.ts_auto-approve-1.aria.yml index d22f1a296b..849b974f52 100644 --- a/e2e-tests/snapshots/auto_approve.spec.ts_auto-approve-1.aria.yml +++ b/e2e-tests/snapshots/auto_approve.spec.ts_auto-approve-1.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: tc=write-index - paragraph: OK, I'm going to do some writing now... - 'button "Index.tsx src/pages/Index.tsx Edit Summary: write-description"' diff --git a/e2e-tests/snapshots/azure_send_message.spec.ts_send-message-through-Azure-OpenAI-1.aria.yml b/e2e-tests/snapshots/azure_send_message.spec.ts_send-message-through-Azure-OpenAI-1.aria.yml index d4aaa8d8cd..ff012f1cac 100644 --- a/e2e-tests/snapshots/azure_send_message.spec.ts_send-message-through-Azure-OpenAI-1.aria.yml +++ b/e2e-tests/snapshots/azure_send_message.spec.ts_send-message-through-Azure-OpenAI-1.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: tc=basic - paragraph: This is a simple basic response - button "Copy" diff --git a/e2e-tests/snapshots/concurrent_chat.spec.ts_concurrent-chat-1.aria.yml b/e2e-tests/snapshots/concurrent_chat.spec.ts_concurrent-chat-1.aria.yml index 4b5d42e7a4..6967033f96 100644 --- a/e2e-tests/snapshots/concurrent_chat.spec.ts_concurrent-chat-1.aria.yml +++ b/e2e-tests/snapshots/concurrent_chat.spec.ts_concurrent-chat-1.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: tc=chat2 - paragraph: chat2 - button "Copy" diff --git a/e2e-tests/snapshots/concurrent_chat.spec.ts_concurrent-chat-2.aria.yml b/e2e-tests/snapshots/concurrent_chat.spec.ts_concurrent-chat-2.aria.yml index b7f4ed1a5e..212a8d7d11 100644 --- a/e2e-tests/snapshots/concurrent_chat.spec.ts_concurrent-chat-2.aria.yml +++ b/e2e-tests/snapshots/concurrent_chat.spec.ts_concurrent-chat-2.aria.yml @@ -1 +1,2 @@ +- button "Restore to this point" - paragraph: tc=chat1 [sleep=medium] diff --git a/e2e-tests/snapshots/engine.spec.ts_regular-auto-should-send-message-to-engine-1.aria.yml b/e2e-tests/snapshots/engine.spec.ts_regular-auto-should-send-message-to-engine-1.aria.yml index eb98eb41d7..1c3a0a56d4 100644 --- a/e2e-tests/snapshots/engine.spec.ts_regular-auto-should-send-message-to-engine-1.aria.yml +++ b/e2e-tests/snapshots/engine.spec.ts_regular-auto-should-send-message-to-engine-1.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: "[dump] tc=turbo-edits" - paragraph: "[[dyad-dump-path=*]]" - button "Copy" diff --git a/e2e-tests/snapshots/engine.spec.ts_send-message-to-engine-1.aria.yml b/e2e-tests/snapshots/engine.spec.ts_send-message-to-engine-1.aria.yml index eea5c3e3b4..a6daa40263 100644 --- a/e2e-tests/snapshots/engine.spec.ts_send-message-to-engine-1.aria.yml +++ b/e2e-tests/snapshots/engine.spec.ts_send-message-to-engine-1.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: "[dump] tc=turbo-edits" - paragraph: "[[dyad-dump-path=*]]" - button "Copy" diff --git a/e2e-tests/snapshots/engine.spec.ts_smart-auto-should-send-message-to-engine-1.aria.yml b/e2e-tests/snapshots/engine.spec.ts_smart-auto-should-send-message-to-engine-1.aria.yml index eb98eb41d7..1c3a0a56d4 100644 --- a/e2e-tests/snapshots/engine.spec.ts_smart-auto-should-send-message-to-engine-1.aria.yml +++ b/e2e-tests/snapshots/engine.spec.ts_smart-auto-should-send-message-to-engine-1.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: "[dump] tc=turbo-edits" - paragraph: "[[dyad-dump-path=*]]" - button "Copy" diff --git a/e2e-tests/snapshots/fix_error.spec.ts_fix-all-errors-button-1.aria.yml b/e2e-tests/snapshots/fix_error.spec.ts_fix-all-errors-button-1.aria.yml index c5e01cd02f..e85e813019 100644 --- a/e2e-tests/snapshots/fix_error.spec.ts_fix-all-errors-button-1.aria.yml +++ b/e2e-tests/snapshots/fix_error.spec.ts_fix-all-errors-button-1.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: tc=create-multiple-errors - paragraph: I will intentionally add multiple errors to test the Fix All Errors button - 'button "Index.tsx src/pages/Index.tsx Edit Summary: intentionally add first error"' @@ -9,6 +10,7 @@ - button "Fix All Errors (3)" - button "Copy" - text: "[[Version 2: files changed]]" +- button "Restore to this point" - paragraph: "Fix all of the following errors:" - list: - listitem: First error in Index diff --git a/e2e-tests/snapshots/fix_error.spec.ts_fix-error-with-AI-3.aria.yml b/e2e-tests/snapshots/fix_error.spec.ts_fix-error-with-AI-3.aria.yml index e7c3e0af8a..877f391ba4 100644 --- a/e2e-tests/snapshots/fix_error.spec.ts_fix-error-with-AI-3.aria.yml +++ b/e2e-tests/snapshots/fix_error.spec.ts_fix-error-with-AI-3.aria.yml @@ -1,8 +1,10 @@ +- button "Restore to this point" - paragraph: tc=create-error - paragraph: I will intentionally add an error - 'button "Index.tsx src/pages/Index.tsx Edit Summary: intentionally add an error"' - button "Copy" - text: "[[Version 2: files changed]]" +- button "Restore to this point" - paragraph: - text: "Fix error: Error Line 6 error Stack trace: Index (" - link "http://localhost:[[port]]/src/pages/Index.tsx:6:6": diff --git a/e2e-tests/snapshots/import.spec.ts_import-app-2.aria.yml b/e2e-tests/snapshots/import.spec.ts_import-app-2.aria.yml index 037adb877d..a345644ccb 100644 --- a/e2e-tests/snapshots/import.spec.ts_import-app-2.aria.yml +++ b/e2e-tests/snapshots/import.spec.ts_import-app-2.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: "[[AI_RULES_GENERATION_PROMPT]]" - button "file1.txt file1.txt Edit" - paragraph: More EOM diff --git a/e2e-tests/snapshots/import.spec.ts_import-app-with-AI-rules-2.aria.yml b/e2e-tests/snapshots/import.spec.ts_import-app-with-AI-rules-2.aria.yml index f5c251fcb7..b96b7f650a 100644 --- a/e2e-tests/snapshots/import.spec.ts_import-app-with-AI-rules-2.aria.yml +++ b/e2e-tests/snapshots/import.spec.ts_import-app-with-AI-rules-2.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: "[dump]" - paragraph: "[[dyad-dump-path=*]]" - button "Copy" diff --git a/e2e-tests/snapshots/lm_studio.spec.ts_send-message-to-LM-studio-1.aria.yml b/e2e-tests/snapshots/lm_studio.spec.ts_send-message-to-LM-studio-1.aria.yml index 32bf8c155c..44b263d7d0 100644 --- a/e2e-tests/snapshots/lm_studio.spec.ts_send-message-to-LM-studio-1.aria.yml +++ b/e2e-tests/snapshots/lm_studio.spec.ts_send-message-to-LM-studio-1.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: hi - button "file1.txt file1.txt Edit" - paragraph: More EOM diff --git a/e2e-tests/snapshots/local_agent_connection_retry.spec.ts_local-agent---recovers-from-connection-drop-1.aria.yml b/e2e-tests/snapshots/local_agent_connection_retry.spec.ts_local-agent---recovers-from-connection-drop-1.aria.yml index 5897d7cd06..232ca7b597 100644 --- a/e2e-tests/snapshots/local_agent_connection_retry.spec.ts_local-agent---recovers-from-connection-drop-1.aria.yml +++ b/e2e-tests/snapshots/local_agent_connection_retry.spec.ts_local-agent---recovers-from-connection-drop-1.aria.yml @@ -1,9 +1,11 @@ +- button "Restore to this point" - paragraph: "[[AI_RULES_GENERATION_PROMPT]]" - button "file1.txt file1.txt Edit" - paragraph: More EOM - button "Copy" - text: "[[Version 2: files changed]]" - button "Copy Request ID" +- button "Restore to this point" - paragraph: tc=local-agent/connection-drop - paragraph: I'll create a file for you. - 'button "recovered.ts src/recovered.ts Edit Summary: File created after connection recovery"' diff --git a/e2e-tests/snapshots/local_agent_explore_code.spec.ts_local-agent---explore-code-experiment-1.aria.yml b/e2e-tests/snapshots/local_agent_explore_code.spec.ts_local-agent---explore-code-experiment-1.aria.yml index 4f2a5830f9..67e7c91b3c 100644 --- a/e2e-tests/snapshots/local_agent_explore_code.spec.ts_local-agent---explore-code-experiment-1.aria.yml +++ b/e2e-tests/snapshots/local_agent_explore_code.spec.ts_local-agent---explore-code-experiment-1.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: tc=local-agent/explore-code - paragraph: I'll inspect the TypeScript symbols around the app component. - 'button "CODE \"App component render flow\" markdown Copy ## explore_code report Query: \"App component render flow\" | Intent: explain | Confidence: low | Action: read_targets Flow: 1. src/App.tsx:1-4 (observed) - compiler-backed symbol window Missing: submit_report was not called Read targets: flow 1 - observed fallback target ```json {\"action\":\"read_targets\",\"confidence\":\"low\",\"paths\":[{\"path\":\"src/App.tsx\",\"range\":\"1-4\"}]} ```" [expanded]' diff --git a/e2e-tests/snapshots/local_agent_file_upload.spec.ts_local-agent---upload-file-to-codebase-1.aria.yml b/e2e-tests/snapshots/local_agent_file_upload.spec.ts_local-agent---upload-file-to-codebase-1.aria.yml index 9cf4d19824..f406129042 100644 --- a/e2e-tests/snapshots/local_agent_file_upload.spec.ts_local-agent---upload-file-to-codebase-1.aria.yml +++ b/e2e-tests/snapshots/local_agent_file_upload.spec.ts_local-agent---upload-file-to-codebase-1.aria.yml @@ -1,9 +1,11 @@ +- button "Restore to this point" - paragraph: "[[AI_RULES_GENERATION_PROMPT]]" - button "file1.txt file1.txt Edit" - paragraph: More EOM - button "Copy" - text: "[[Version 2: files changed]]" - button "Copy Request ID" +- button "Restore to this point" - paragraph: tc=local-agent/upload-to-codebase - 'button "Expand image: logo.png"' - paragraph: I'll upload your file to the codebase. diff --git a/e2e-tests/snapshots/local_agent_generate_image.spec.ts_local-agent---generate-image-1.aria.yml b/e2e-tests/snapshots/local_agent_generate_image.spec.ts_local-agent---generate-image-1.aria.yml index 35660acd01..a98f4018f7 100644 --- a/e2e-tests/snapshots/local_agent_generate_image.spec.ts_local-agent---generate-image-1.aria.yml +++ b/e2e-tests/snapshots/local_agent_generate_image.spec.ts_local-agent---generate-image-1.aria.yml @@ -1,9 +1,11 @@ +- button "Restore to this point" - paragraph: "[[AI_RULES_GENERATION_PROMPT]]" - button "file1.txt file1.txt Edit" - paragraph: More EOM - button "Copy" - text: "[[Version 2: files changed]]" - button "Copy Request ID" +- button "Restore to this point" - paragraph: tc=local-agent/generate-image - paragraph: I'll generate a hero image for your landing page. - button "Image Generation A modern, minimal hero illustration of a rocket launching from a laptop screen, flat design style, blue and purple gradient background, clean lines View generated image" diff --git a/e2e-tests/snapshots/local_agent_persistent_todos.spec.ts_local-agent---persistent-todos-across-turns-1.aria.yml b/e2e-tests/snapshots/local_agent_persistent_todos.spec.ts_local-agent---persistent-todos-across-turns-1.aria.yml index 2513473095..cfe2584706 100644 --- a/e2e-tests/snapshots/local_agent_persistent_todos.spec.ts_local-agent---persistent-todos-across-turns-1.aria.yml +++ b/e2e-tests/snapshots/local_agent_persistent_todos.spec.ts_local-agent---persistent-todos-across-turns-1.aria.yml @@ -1,9 +1,11 @@ +- button "Restore to this point" - paragraph: "[[AI_RULES_GENERATION_PROMPT]]" - button "file1.txt file1.txt Edit" - paragraph: More EOM - button "Copy" - text: "[[Version 2: files changed]]" - button "Copy Request ID" +- button "Restore to this point" - paragraph: tc=local-agent/persistent-todos - paragraph: I'll create a task list to track the work.Let me create the utility module first. - 'button "utils.ts src/lib/utils.ts Edit Summary: Create utility module"' @@ -11,6 +13,7 @@ - button "Copy" - text: "[[Version 3: files changed]]" - button "Copy Request ID" +- button "Restore to this point" - paragraph: tc=local-agent/persistent-todos-resume - paragraph: I see there are unfinished todos from last time. Let me continue.Adding error handling to the utility module. - 'button "utils.ts src/lib/utils.ts Edit Summary: Add error handling utility"' diff --git a/e2e-tests/snapshots/local_agent_summarize.spec.ts_local-agent---summarize-to-new-chat-works-1.aria.yml b/e2e-tests/snapshots/local_agent_summarize.spec.ts_local-agent---summarize-to-new-chat-works-1.aria.yml index a1a7e8c6ea..e0c14bd3be 100644 --- a/e2e-tests/snapshots/local_agent_summarize.spec.ts_local-agent---summarize-to-new-chat-works-1.aria.yml +++ b/e2e-tests/snapshots/local_agent_summarize.spec.ts_local-agent---summarize-to-new-chat-works-1.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: Summarize from chat-id=1 - button "file1.txt file1.txt Edit" - paragraph: More EOM diff --git a/e2e-tests/snapshots/local_agent_todo_followup.spec.ts_local-agent---todo-follow-up-loop-1.aria.yml b/e2e-tests/snapshots/local_agent_todo_followup.spec.ts_local-agent---todo-follow-up-loop-1.aria.yml index b799e3af03..600cc52c4a 100644 --- a/e2e-tests/snapshots/local_agent_todo_followup.spec.ts_local-agent---todo-follow-up-loop-1.aria.yml +++ b/e2e-tests/snapshots/local_agent_todo_followup.spec.ts_local-agent---todo-follow-up-loop-1.aria.yml @@ -1,9 +1,11 @@ +- button "Restore to this point" - paragraph: "[[AI_RULES_GENERATION_PROMPT]]" - button "file1.txt file1.txt Edit" - paragraph: More EOM - button "Copy" - text: "[[Version 2: files changed]]" - button "Copy Request ID" +- button "Restore to this point" - paragraph: tc=local-agent/todo-followup-loop - paragraph: I'll create a todo list to track these tasks.Let me create the utility function first. - 'button "helper.ts src/utils/helper.ts Edit Summary: Create helper utility function"' diff --git a/e2e-tests/snapshots/local_agent_web_fetch.spec.ts_local-agent---web-fetch-1.aria.yml b/e2e-tests/snapshots/local_agent_web_fetch.spec.ts_local-agent---web-fetch-1.aria.yml index e06763c387..281cbacccd 100644 --- a/e2e-tests/snapshots/local_agent_web_fetch.spec.ts_local-agent---web-fetch-1.aria.yml +++ b/e2e-tests/snapshots/local_agent_web_fetch.spec.ts_local-agent---web-fetch-1.aria.yml @@ -1,9 +1,11 @@ +- button "Restore to this point" - paragraph: "[[AI_RULES_GENERATION_PROMPT]]" - button "file1.txt file1.txt Edit" - paragraph: More EOM - button "Copy" - text: "[[Version 2: files changed]]" - button "Copy Request ID" +- button "Restore to this point" - paragraph: tc=local-agent/web-fetch - paragraph: I'll fetch the content of that page for you. - text: Web Fetch diff --git a/e2e-tests/snapshots/main.spec.ts_simple-message-to-custom-test-model-1.aria.yml b/e2e-tests/snapshots/main.spec.ts_simple-message-to-custom-test-model-1.aria.yml index 44391854ce..ed3f15a357 100644 --- a/e2e-tests/snapshots/main.spec.ts_simple-message-to-custom-test-model-1.aria.yml +++ b/e2e-tests/snapshots/main.spec.ts_simple-message-to-custom-test-model-1.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: hi - button "file1.txt file1.txt Edit" - paragraph: More EOM diff --git a/e2e-tests/snapshots/ollama.spec.ts_send-message-to-ollama-1.aria.yml b/e2e-tests/snapshots/ollama.spec.ts_send-message-to-ollama-1.aria.yml index 5dba21a7de..8851e75ca9 100644 --- a/e2e-tests/snapshots/ollama.spec.ts_send-message-to-ollama-1.aria.yml +++ b/e2e-tests/snapshots/ollama.spec.ts_send-message-to-ollama-1.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: hi - button "file1.txt file1.txt Edit" - paragraph: More EOM diff --git a/e2e-tests/snapshots/partial_response.spec.ts_partial-message-is-resumed-1.aria.yml b/e2e-tests/snapshots/partial_response.spec.ts_partial-message-is-resumed-1.aria.yml index 054066f827..f9d0f543ea 100644 --- a/e2e-tests/snapshots/partial_response.spec.ts_partial-message-is-resumed-1.aria.yml +++ b/e2e-tests/snapshots/partial_response.spec.ts_partial-message-is-resumed-1.aria.yml @@ -1,8 +1,10 @@ +- button "Restore to this point" - paragraph: "[[AI_RULES_GENERATION_PROMPT]]" - button "file1.txt file1.txt Edit" - paragraph: More EOM - button "Copy" - text: "[[Version 2: files changed]]" +- button "Restore to this point" - paragraph: tc=partial-write - paragraph: START OF MESSAGE - 'button "new-file.ts src/new-file.ts Edit Summary: this file will be partially written"' diff --git a/e2e-tests/snapshots/problems.spec.ts_problems---fix-all-1.aria.yml b/e2e-tests/snapshots/problems.spec.ts_problems---fix-all-1.aria.yml index 42e8e7e029..a848a93fe9 100644 --- a/e2e-tests/snapshots/problems.spec.ts_problems---fix-all-1.aria.yml +++ b/e2e-tests/snapshots/problems.spec.ts_problems---fix-all-1.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: tc=create-ts-errors - paragraph: This will get a TypeScript error. - 'button "bad-file.ts src/bad-file.ts Edit Summary: This will get a TypeScript error."' @@ -11,6 +12,7 @@ - paragraph: More EOM - paragraph: "[[dyad-dump-path=*]]" - button "Copy" +- button "Restore to this point" - paragraph: "Fix these 3 TypeScript compile-time errors:" - list: - listitem: src/bad-file.tsx:2:1 - Cannot find name 'nonExistentFunction1'. (TS2304) diff --git a/e2e-tests/snapshots/problems.spec.ts_problems---select-specific-problems-and-fix-1.aria.yml b/e2e-tests/snapshots/problems.spec.ts_problems---select-specific-problems-and-fix-1.aria.yml index f5fd2f8c89..89f7038ea7 100644 --- a/e2e-tests/snapshots/problems.spec.ts_problems---select-specific-problems-and-fix-1.aria.yml +++ b/e2e-tests/snapshots/problems.spec.ts_problems---select-specific-problems-and-fix-1.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: "Fix these 2 TypeScript compile-time errors:" - list: - listitem: src/bad-file.tsx:2:1 - Cannot find name 'nonExistentFunction1'. (TS2304) diff --git a/e2e-tests/snapshots/problems.spec.ts_problems-auto-fix---complex-delete-rename-write-1.aria.yml b/e2e-tests/snapshots/problems.spec.ts_problems-auto-fix---complex-delete-rename-write-1.aria.yml index acb9232676..26bb7b5680 100644 --- a/e2e-tests/snapshots/problems.spec.ts_problems-auto-fix---complex-delete-rename-write-1.aria.yml +++ b/e2e-tests/snapshots/problems.spec.ts_problems-auto-fix---complex-delete-rename-write-1.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: tc=create-ts-errors-complex - paragraph: Tests delete-rename-write order - text: main.tsx Delete src/main.tsx diff --git a/e2e-tests/snapshots/problems.spec.ts_problems-auto-fix---disabled-1.aria.yml b/e2e-tests/snapshots/problems.spec.ts_problems-auto-fix---disabled-1.aria.yml index 56a1b79ae0..27cae52795 100644 --- a/e2e-tests/snapshots/problems.spec.ts_problems-auto-fix---disabled-1.aria.yml +++ b/e2e-tests/snapshots/problems.spec.ts_problems-auto-fix---disabled-1.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: tc=create-ts-errors - paragraph: This will get a TypeScript error. - 'button "bad-file.ts src/bad-file.ts Edit Summary: This will get a TypeScript error."' diff --git a/e2e-tests/snapshots/problems.spec.ts_problems-auto-fix---enabled-1.aria.yml b/e2e-tests/snapshots/problems.spec.ts_problems-auto-fix---enabled-1.aria.yml index 04c4179056..2cb7801845 100644 --- a/e2e-tests/snapshots/problems.spec.ts_problems-auto-fix---enabled-1.aria.yml +++ b/e2e-tests/snapshots/problems.spec.ts_problems-auto-fix---enabled-1.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: tc=create-ts-errors - paragraph: This will get a TypeScript error. - 'button "bad-file.ts src/bad-file.ts Edit Summary: This will get a TypeScript error."' diff --git a/e2e-tests/snapshots/problems.spec.ts_problems-auto-fix---gives-up-after-2-attempts-messages.aria.yml b/e2e-tests/snapshots/problems.spec.ts_problems-auto-fix---gives-up-after-2-attempts-messages.aria.yml index 0087a201f2..21c98be286 100644 --- a/e2e-tests/snapshots/problems.spec.ts_problems-auto-fix---gives-up-after-2-attempts-messages.aria.yml +++ b/e2e-tests/snapshots/problems.spec.ts_problems-auto-fix---gives-up-after-2-attempts-messages.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: tc=create-unfixable-ts-errors - paragraph: This should not get fixed - 'button "bad-file.ts src/bad-file.ts Edit Summary: This will produce 5 TypeScript errors."' diff --git a/e2e-tests/snapshots/security_review.spec.ts_security-review---fix-issue.aria.yml b/e2e-tests/snapshots/security_review.spec.ts_security-review---fix-issue.aria.yml index a00fb2fd3a..60619ac963 100644 --- a/e2e-tests/snapshots/security_review.spec.ts_security-review---fix-issue.aria.yml +++ b/e2e-tests/snapshots/security_review.spec.ts_security-review---fix-issue.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: "Please fix the following security issue in a simple and effective way:" - paragraph: - strong: SQL Injection in User Lookup diff --git a/e2e-tests/snapshots/smart_context_balanced.spec.ts_smart-context-balanced---simple-1.aria.yml b/e2e-tests/snapshots/smart_context_balanced.spec.ts_smart-context-balanced---simple-1.aria.yml index c0ecf1f8bd..8c863ad811 100644 --- a/e2e-tests/snapshots/smart_context_balanced.spec.ts_smart-context-balanced---simple-1.aria.yml +++ b/e2e-tests/snapshots/smart_context_balanced.spec.ts_smart-context-balanced---simple-1.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: "[dump]" - paragraph: "[[dyad-dump-path=*]]" - button "Copy" diff --git a/e2e-tests/snapshots/smart_context_deep.spec.ts_smart-context-deep---read-write-read-1.aria.yml b/e2e-tests/snapshots/smart_context_deep.spec.ts_smart-context-deep---read-write-read-1.aria.yml index 82e0ef59c2..32752993cb 100644 --- a/e2e-tests/snapshots/smart_context_deep.spec.ts_smart-context-deep---read-write-read-1.aria.yml +++ b/e2e-tests/snapshots/smart_context_deep.spec.ts_smart-context-deep---read-write-read-1.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: tc=read-index - paragraph: "Read the index page:" - text: Read src/pages/Index.tsx @@ -5,6 +6,7 @@ - button "Copy" - text: auto - button "Copy Request ID" +- button "Restore to this point" - paragraph: tc=update-index-1 - paragraph: First read - 'button "Index.tsx src/pages/Index.tsx Edit Summary: replace file"' @@ -12,6 +14,7 @@ - text: auto - text: "[[Version 2: files changed]]" - button "Copy Request ID" +- button "Restore to this point" - paragraph: tc=read-index - paragraph: "Read the index page:" - text: Read src/pages/Index.tsx @@ -19,6 +22,7 @@ - button "Copy" - text: auto - button "Copy Request ID" +- button "Restore to this point" - paragraph: "[dump]" - paragraph: "[[dyad-dump-path=*]]" - button "Copy" diff --git a/e2e-tests/snapshots/turbo_edits_v2.spec.ts_turbo-edits-v2---search-replace-approve-1.aria.yml b/e2e-tests/snapshots/turbo_edits_v2.spec.ts_turbo-edits-v2---search-replace-approve-1.aria.yml index 336870821e..8ee42d2093 100644 --- a/e2e-tests/snapshots/turbo_edits_v2.spec.ts_turbo-edits-v2---search-replace-approve-1.aria.yml +++ b/e2e-tests/snapshots/turbo_edits_v2.spec.ts_turbo-edits-v2---search-replace-approve-1.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: tc=turbo-edits-v2 - paragraph: Example with turbo edit v2 - button "Search & Replace Index.tsx src/pages/Index.tsx" diff --git a/e2e-tests/snapshots/turbo_edits_v2.spec.ts_turbo-edits-v2---search-replace-fallback-1.aria.yml b/e2e-tests/snapshots/turbo_edits_v2.spec.ts_turbo-edits-v2---search-replace-fallback-1.aria.yml index 461df5648a..5bab832f16 100644 --- a/e2e-tests/snapshots/turbo_edits_v2.spec.ts_turbo-edits-v2---search-replace-fallback-1.aria.yml +++ b/e2e-tests/snapshots/turbo_edits_v2.spec.ts_turbo-edits-v2---search-replace-fallback-1.aria.yml @@ -1,3 +1,4 @@ +- button "Restore to this point" - paragraph: tc=turbo-edits-v2-trigger-fallback - paragraph: Example with turbo edit v2 - button "Search & Replace Index.tsx src/pages/Index.tsx" diff --git a/src/components/chat/ChatMessage.tsx b/src/components/chat/ChatMessage.tsx index 6d64ad91e6..718f6a461b 100644 --- a/src/components/chat/ChatMessage.tsx +++ b/src/components/chat/ChatMessage.tsx @@ -1,4 +1,4 @@ -import type { Message } from "@/ipc/types"; +import { ipc, type Message } from "@/ipc/types"; import { DyadMarkdownParser, VanillaMarkdownParser, @@ -16,9 +16,13 @@ import { Info, Bot, Ban, + Undo2, + Loader2, } from "lucide-react"; import { formatDistanceToNow, format } from "date-fns"; import { useVersions } from "@/hooks/useVersions"; +import { useSelectChat } from "@/hooks/useSelectChat"; +import { showError } from "@/lib/toast"; import { useAtomValue } from "jotai"; import { selectAtom } from "jotai/utils"; import { selectedAppIdAtom } from "@/atoms/appAtoms"; @@ -33,6 +37,16 @@ import { TooltipTrigger, TooltipContent, } from "@/components/ui/tooltip"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { buttonVariants } from "@/components/ui/button"; import { unescapeXmlAttr } from "../../../shared/xmlEscape"; import { isCancelledResponseContent, @@ -96,7 +110,13 @@ const ChatMessage = ({ }: ChatMessageProps) => { const { isStreaming } = useStreamChat(); const appId = useAtomValue(selectedAppIdAtom); - const { versions: liveVersions } = useVersions(appId); + const { + versions: liveVersions, + restoreToMessage, + isRestoringToMessage, + isAnyVersionMutationPending, + } = useVersions(appId); + const { selectChat } = useSelectChat(); const assistantTextContent = message.role === "assistant" ? stripCancelledResponseNotice(message.content) @@ -114,6 +134,7 @@ const ChatMessage = ({ [selectedChatId], ); const hasPreviewForChat = useAtomValue(hasPreviewForChatAtom); + const [showRestoreConfirm, setShowRestoreConfirm] = useState(false); const hasStreamingPreview = message.role === "assistant" && isLastMessage && @@ -189,6 +210,104 @@ const ChatMessage = ({ const attachmentSize: AttachmentSize = attachments.length === 1 ? "lg" : attachments.length <= 3 ? "md" : "sm"; + // Exclude cancelled prompts: they render greyed out with a "Cancelled" label, + // and showing an undo arrow on a turn that never completed (and may have left + // partial file changes) is confusing. + // Attachment-only prompts (no text) are still accepted and can lead to code + // changes, so they get a restore arrow too — anchored to the attachment block + // below rather than the (absent) message box. + const showRestoreButton = + message.role === "user" && + (hasUserText || attachments.length > 0) && + !isCancelled; + + const handleRestoreToMessage = async (restoreCodebase: boolean) => { + if (appId == null || selectedChatId == null) { + return; + } + setShowRestoreConfirm(false); + try { + // Only the code-restore path mutates git state, so it must wait for the + // active stream to fully cancel first (avoiding a race with the revert). + // Fork-only leaves the codebase untouched, so there's no reason to abort + // the original chat's in-progress generation. + if (isStreaming && restoreCodebase) { + await ipc.chat.cancelStream(selectedChatId); + } + const result = await restoreToMessage({ + chatId: selectedChatId, + messageId: message.id, + restoreCodebase, + }); + // A `newChatId` is only returned when a new chat was actually created. If + // no version could be determined, we stay on the current chat (the user + // still sees the warning toast). + if ("newChatId" in result) { + selectChat({ chatId: result.newChatId, appId }); + } + } catch (error) { + showError(error); + } + }; + + // The restore button + confirmation dialog, anchored absolutely to the + // top-right of its (relatively-positioned) container. Rendered inside the + // message box for text prompts, and next to the attachment block for + // attachment-only prompts (which have no message box). + const restoreButtonNode = showRestoreButton ? ( + <> + + setShowRestoreConfirm(true)} + disabled={isAnyVersionMutationPending} + aria-label="Restore to this point" + className="absolute -top-2 -right-2 z-10 flex h-6 w-6 items-center justify-center rounded-full border bg-(--background) text-gray-500 shadow-sm transition-colors duration-200 hover:text-gray-700 disabled:cursor-not-allowed dark:text-gray-400 dark:hover:text-gray-200" + /> + } + > + {isRestoringToMessage ? ( + + ) : ( + + )} + + + Fork or restore to before this message in a new chat + + + + + + Restore to this point? + + + handleRestoreToMessage(true)} + > + Restore code & fork chat + + handleRestoreToMessage(false)} + > + Fork chat only + + Cancel + + + + + ) : null; + return (
+ {/* Text prompts anchor the button to the message box; attachment-only + prompts render it next to the attachment block below instead. */} + {hasUserText && restoreButtonNode} {message.role === "assistant" && !hasAssistantText && isStreaming && @@ -294,7 +418,10 @@ const ChatMessage = ({ )} {/* Render attachments outside the message box */} {attachments.length > 0 && ( -
+
+ {/* Attachment-only prompts have no message box, so anchor the restore + button here instead. Text prompts render it above. */} + {!hasUserText && restoreButtonNode} {attachments.map((att, i) => ( ; @@ -107,6 +108,7 @@ function VersionRow({ selectedVersionId, isCheckingOutVersion, isRevertingVersion, + isAnyVersionMutationPending, showNoteEditor, shouldAutoFocusNote, versionNumberByOid, @@ -304,11 +306,12 @@ function VersionRow({ e.stopPropagation(); onRestoreVersion(version); }} - disabled={isRevertingVersion} + disabled={isAnyVersionMutationPending} className={cn( "invisible mt-1 flex items-center gap-1 px-2 py-0.5 text-sm font-medium bg-(--primary) text-(--primary-foreground) hover:bg-background-lightest rounded-md transition-colors", selectedVersionId === version.oid && "visible", - isRevertingVersion && "opacity-50 cursor-not-allowed", + isAnyVersionMutationPending && + "opacity-50 cursor-not-allowed", )} aria-label="Restore to this version" title={ @@ -346,6 +349,7 @@ export function VersionPane({ isVisible, onClose }: VersionPaneProps) { isRevertingVersion, setVersionFavorite, setVersionNote, + isAnyVersionMutationPending, } = useVersions(appId); const [selectedVersionId, setSelectedVersionId] = useAtom( @@ -818,6 +822,7 @@ export function VersionPane({ isVisible, onClose }: VersionPaneProps) { selectedVersionId={selectedVersionId} isCheckingOutVersion={isCheckingOutVersion} isRevertingVersion={isRevertingVersion} + isAnyVersionMutationPending={isAnyVersionMutationPending} showNoteEditor={ expandedNoteVersionIds.has(version.oid) || !!version.note } diff --git a/src/hooks/useVersions.ts b/src/hooks/useVersions.ts index 8b8befc11e..18a415f5ce 100644 --- a/src/hooks/useVersions.ts +++ b/src/hooks/useVersions.ts @@ -1,14 +1,32 @@ import { useAtomValue, useSetAtom } from "jotai"; -import { ipc, type RevertVersionResponse, type Version } from "@/ipc/types"; +import { + ipc, + type RestoreToMessageResponse, + type RevertVersionResponse, + type Version, +} from "@/ipc/types"; import { chatMessagesByIdAtom, selectedChatIdAtom } from "@/atoms/chatAtoms"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + useQuery, + useMutation, + useQueryClient, + useIsMutating, +} from "@tanstack/react-query"; import { queryKeys } from "@/lib/queryKeys"; import { toast } from "sonner"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { useRunApp } from "./useRunApp"; import { useSettings } from "./useSettings"; +// Shared keys so every `useVersions` instance can observe whether *any* +// version-modifying operation is in flight (via `useIsMutating`), not just its +// own. Both operations are serialized by `withLock(appId)` on the backend, but +// we also want to prevent the UI from kicking off a second one against state +// left by the first. +const restoreToMessageMutationKey = ["restoreToMessageVersion"] as const; +const revertVersionMutationKey = ["revertVersion"] as const; + export function useVersions(appId: number | null) { const selectedChatId = useAtomValue(selectedChatIdAtom); const setMessagesById = useSetAtom(chatMessagesByIdAtom); @@ -57,6 +75,7 @@ export function useVersions(appId: number | null) { targetBranchName?: string; } >({ + mutationKey: revertVersionMutationKey, mutationFn: async ({ versionId, currentChatMessageId, @@ -159,6 +178,97 @@ export function useVersions(appId: number | null) { meta: { showErrorToast: true }, }); + const restoreToMessageMutation = useMutation< + RestoreToMessageResponse, + Error, + { chatId: number; messageId: number; restoreCodebase: boolean }, + { mutationAppId: number | null } + >({ + mutationKey: restoreToMessageMutationKey, + // Capture the app the mutation targets so `onSuccess` invalidates *that* + // app's caches. If the user switches apps while the IPC call is in flight, + // the hook's `appId` closure would point at the newly selected app, leaving + // the restored app's version/branch/problem caches stale. + onMutate: () => ({ mutationAppId: appId }), + mutationFn: async ({ chatId, messageId, restoreCodebase }) => { + const currentAppId = appId; + if (currentAppId === null) { + throw new DyadError("App ID is null", DyadErrorKind.External); + } + return ipc.version.restoreToMessageVersion({ + appId: currentAppId, + chatId, + messageId, + restoreCodebase, + }); + }, + onSuccess: async (result, _variables, context) => { + // Invalidate the app the mutation ran against (see `onMutate`), falling + // back to the current closure `appId` if no context was captured. + const restoredAppId = context?.mutationAppId ?? appId; + if ("warningMessage" in result) { + // When `newChatId` is present the codebase *was* reverted and only a + // secondary step (e.g. the Neon DB restore) failed. Make the partial + // success explicit so the user isn't left thinking nothing happened. + if ("newChatId" in result) { + toast.warning(`Code restored, but: ${result.warningMessage}`); + } else { + toast.warning(result.warningMessage); + } + } else { + toast.success(result.successMessage); + } + // These invalidations are independent, so run them concurrently. Since + // `mutateAsync` only resolves after `onSuccess` completes, awaiting them + // sequentially would delay navigation to the forked chat (the caller + // navigates once `mutateAsync` resolves), leaving the user on a spinner. + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: queryKeys.versions.list({ appId: restoredAppId }), + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.branches.current({ appId: restoredAppId }), + }), + // restoreToMessageVersion creates a brand new chat, so refresh the chat + // list (like every other chat-creation path) or the sidebar won't show + // it. + queryClient.invalidateQueries({ + queryKey: queryKeys.chats.all, + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.problems.byApp({ appId: restoredAppId }), + }), + ]); + if (settings?.runtimeMode2 === "cloud") { + await restartApp(); + } + }, + // No `meta.showErrorToast` here: `handleRestoreToMessage` in + // `ChatMessage.tsx` already wraps this `mutateAsync` (and the preceding + // `cancelStream` call) in a try/catch that surfaces the error via + // `showError`. Enabling the global toast too would show the same error + // twice. + }); + + // True when *any* version-modifying operation (restore-to-message or + // revert-version) is pending across every `useVersions` instance. The + // per-instance `isPending` flags above are local to the component that + // triggered them, so we use `useIsMutating` on the shared keys to disable all + // version-modifying buttons (message restore arrows and the version-pane + // revert button) while one is running, preventing a confusing second + // operation from running against the state left by the first. + // Both `useIsMutating` calls must run on every render — combining them with + // `||` directly would short-circuit and skip the second hook whenever the + // first is truthy, violating the rules of hooks ("Should have a queue"). + const restoreToMessagePending = useIsMutating({ + mutationKey: restoreToMessageMutationKey, + }); + const revertVersionPending = useIsMutating({ + mutationKey: revertVersionMutationKey, + }); + const isAnyVersionMutationPending = + restoreToMessagePending > 0 || revertVersionPending > 0; + return { versions: versions || [], loading, @@ -170,5 +280,8 @@ export function useVersions(appId: number | null) { isSettingVersionFavorite: setVersionFavoriteMutation.isPending, setVersionNote: setVersionNoteMutation.mutateAsync, isSettingVersionNote: setVersionNoteMutation.isPending, + restoreToMessage: restoreToMessageMutation.mutateAsync, + isRestoringToMessage: restoreToMessageMutation.isPending, + isAnyVersionMutationPending, }; } diff --git a/src/ipc/handlers/chat_stream_handlers.ts b/src/ipc/handlers/chat_stream_handlers.ts index 9275f7fe98..7743d6efa1 100644 --- a/src/ipc/handlers/chat_stream_handlers.ts +++ b/src/ipc/handlers/chat_stream_handlers.ts @@ -162,6 +162,12 @@ const logger = log.scope("chat_stream_handlers"); // Track active streams for cancellation const activeStreams = new Map(); +// Resolves when a stream's handler has fully unwound (its `finally` block ran, +// so any in-flight tool/file writes have settled). `cancelStream` awaits this +// after aborting so callers like restore-to-message don't touch the working +// tree while a cancelled turn is still flushing partial file writes. +const streamCompletions = new Map>(); + // Track partial responses for cancelled streams const partialResponses = new Map(); @@ -272,6 +278,7 @@ export function registerChatStreamHandlers() { } activeStreams.clear(); partialResponses.clear(); + streamCompletions.clear(); }); createTypedHandler( @@ -283,6 +290,15 @@ export function registerChatStreamHandlers() { ipcMain.handle("chat:stream", async (event, req: ChatStreamParams) => { let attachmentPaths: string[] = []; + // Expose a promise that resolves once this handler fully unwinds (see the + // `finally` block) so `cancelStream` can await in-flight tool/file writes. + let resolveCompletion: () => void = () => {}; + streamCompletions.set( + req.chatId, + new Promise((resolve) => { + resolveCompletion = resolve; + }), + ); try { let dyadRequestId: string | undefined; // Create an AbortController for this stream @@ -1978,12 +1994,20 @@ ${problemReport.problems safeSend(event.sender, "chat:stream:end", { chatId: req.chatId }); // Unblock any pending MCP consents (their banners are cleared on stream end). clearPendingMcpConsentsForChat(req.chatId); + + // Signal any awaiting `cancelStream` call that all writes have settled, + // then drop the (now-resolved) completion promise for this chat. Resolve + // before deleting so a reader that consults the map after the abort still + // observes a settled promise rather than a missing entry. + resolveCompletion(); + streamCompletions.delete(req.chatId); } }); // Handler to cancel an ongoing stream createTypedHandler(chatContracts.cancelStream, async (event, chatId) => { const abortController = activeStreams.get(chatId); + const completion = streamCompletions.get(chatId); if (abortController) { // Abort the stream @@ -1994,6 +2018,20 @@ ${problemReport.problems logger.warn(`No active stream found for chat ${chatId}`); } + // Unblock any pending MCP consents *before* awaiting completion. A stream + // parked on a consent prompt won't unwind until that consent resolves, so + // awaiting `completion` first would hang indefinitely. Resolving the + // consents as declined here lets the handler finish so `completion` settles. + clearPendingMcpConsentsForChat(chatId); + + // Wait for the in-flight stream handler to fully unwind before returning so + // callers (e.g. restore-to-message) know any partial tool/file writes have + // settled and won't race a subsequent git revert. The handler logs its own + // errors, so a rejected completion here is not actionable. + if (completion) { + await completion.catch(() => {}); + } + // Send the end event to the renderer with wasCancelled flag safeSend(event.sender, "chat:response:end", { chatId, diff --git a/src/ipc/handlers/version_handlers.ts b/src/ipc/handlers/version_handlers.ts index 9f03cf1e99..cfcee69fe8 100644 --- a/src/ipc/handlers/version_handlers.ts +++ b/src/ipc/handlers/version_handlers.ts @@ -1,5 +1,5 @@ import { db } from "../../db"; -import { apps, messages, versions } from "../../db/schema"; +import { apps, chats, messages, versions } from "../../db/schema"; import { desc, eq, and, gt, gte } from "drizzle-orm"; import type { GitCommit } from "../git_types"; import fs from "node:fs"; @@ -13,6 +13,7 @@ import { versionContracts } from "../types/version"; import { deployAllSupabaseFunctions } from "../../supabase_admin/supabase_utils"; import { readSettings } from "../../main/settings"; import { + gitAddAll, gitCheckout, gitCommit, gitStageToRevert, @@ -210,6 +211,205 @@ async function restoreBranchForPreview({ ); } +/** + * Reverts the app's codebase (and Neon DB / Supabase functions / cloud sandbox) + * to the given version. This does NOT modify any chat messages and does NOT take + * the per-app lock — callers are responsible for holding `withLock(appId)`. + */ +async function revertCodebaseToVersion({ + appId, + app, + appPath, + previousVersionId, + targetBranchName, +}: { + appId: number; + app: typeof apps.$inferSelect; + appPath: string; + previousVersionId: string; + targetBranchName?: string; +}): Promise<{ successMessage: string; warningMessage: string }> { + let successMessage = "Restored version"; + let warningMessage = ""; + + const currentBranch = await gitCurrentBranch({ path: appPath }); + const revertRef = currentBranch ?? targetBranchName ?? "HEAD"; + // Get the current commit hash before reverting + const currentCommitHash = await getCurrentCommitHash({ + path: appPath, + ref: revertRef, + }); + + await gitCheckout({ + path: appPath, + ref: revertRef, + }); + + // A cancelled/aborted stream leaves the AI's partial file writes uncommitted + // in the working tree (`cancelStream` only aborts; it never commits). That + // would make `gitStageToRevert` refuse to run ("working tree has uncommitted + // changes"). Commit those pending changes first so they're preserved as a + // version (consistent with Dyad's one-commit-per-turn model) and the revert + // can proceed against a clean tree. This must run before + // `storeDbTimestampAtCurrentVersion` so the Neon timestamp binds to the + // committed state rather than the soon-to-be-discarded dirty tree. + if (!(await isGitStatusClean({ path: appPath }))) { + // Stage everything first so untracked files (e.g. a newly added + // pnpm-workspace.yaml) are included. With native git, `git commit` only + // commits staged changes, so without this the commit would fail with + // "nothing added to commit but untracked files present" and the revert + // would abort. + await gitAddAll({ path: appPath }); + await gitCommit({ + path: appPath, + message: + "Saved uncommitted changes before restoring to an earlier version", + }); + } + + if (app.neonProjectId && app.neonDevelopmentBranchId) { + // We are going to add a new commit on top, so let's store + // the current timestamp at the current version. + await storeDbTimestampAtCurrentVersion({ + appId, + }); + } + + await gitStageToRevert({ + path: appPath, + targetOid: previousVersionId, + }); + const isClean = await isGitStatusClean({ path: appPath }); + if (!isClean) { + await gitCommit({ + path: appPath, + message: `Reverted all changes back to version ${previousVersionId}`, + }); + } + + if (app.neonProjectId && app.neonDevelopmentBranchId) { + const version = await db.query.versions.findFirst({ + where: and( + eq(versions.appId, appId), + eq(versions.commitHash, previousVersionId), + ), + }); + if (version && version.neonDbTimestamp) { + try { + const preserveBranchName = `preserve_${currentCommitHash}-${Date.now()}`; + const neonClient = await getNeonClient(); + const response = await retryOnLocked( + () => + neonClient.restoreProjectBranch( + app.neonProjectId!, + app.neonDevelopmentBranchId!, + { + source_branch_id: app.neonDevelopmentBranchId!, + source_timestamp: version.neonDbTimestamp!, + preserve_under_name: preserveBranchName, + }, + ), + `Restore development branch ${app.neonDevelopmentBranchId} for app ${appId}`, + ); + // Update all versions which have a newer DB timestamp than the version we're restoring to + // and remove their DB timestamp. + await db + .update(versions) + .set({ neonDbTimestamp: null }) + .where( + and( + eq(versions.appId, appId), + gt(versions.neonDbTimestamp, version.neonDbTimestamp), + ), + ); + + const preserveBranchId = response.data.branch.parent_id; + if (!preserveBranchId) { + throw new DyadError( + "Preserve branch ID not found", + DyadErrorKind.NotFound, + ); + } + logger.info( + `Deleting preserve branch ${preserveBranchId} for app ${appId}`, + ); + // Intentionally do not await this because it's not + // critical for the restore operation, it's to clean up branches + // so the user doesn't hit the branch limit later. The error is + // handled via `.catch()` rather than a surrounding try/catch + // because, without an `await`, a try/catch would not catch the + // promise rejection and it would surface as an unhandled rejection. + retryOnLocked( + () => + neonClient.deleteProjectBranch( + app.neonProjectId!, + preserveBranchId, + ), + `Delete preserve branch ${preserveBranchId} for app ${appId}`, + { retryBranchWithChildError: true }, + ).catch((error) => { + const errorMessage = getNeonErrorMessage(error); + logger.error("Error in deleteProjectBranch:", errorMessage); + }); + // Only claim the database was included when the restore actually + // succeeded. Setting this after the catch would wrongly report + // "(including database)" even when the Neon restore failed and + // `warningMessage` was set. + successMessage = + "Successfully restored to version (including database)"; + } catch (error) { + logger.error( + "Error restoring Neon development branch during revert:", + getNeonErrorMessage(error), + ); + // Use the shared helper so the retention-window case gets its + // user-friendly explanation instead of a raw error string. + warningMessage = getDatabaseRestoreWarning(error); + // Do not throw, so we can finish switching the postgres branch + // It might throw because they picked a timestamp that's too old. + } + } + await switchPostgresToDevelopmentBranch({ + neonProjectId: app.neonProjectId, + neonDevelopmentBranchId: app.neonDevelopmentBranchId, + appPath: app.path, + }); + } + // Re-deploy all Supabase edge functions after reverting + if (app.supabaseProjectId) { + try { + logger.info( + `Re-deploying all Supabase edge functions for app ${appId} after revert`, + ); + const settings = readSettings(); + const deployErrors = await deployAllSupabaseFunctions({ + appPath, + supabaseProjectId: app.supabaseProjectId, + supabaseOrganizationSlug: app.supabaseOrganizationSlug ?? null, + skipPruneEdgeFunctions: settings.skipPruneEdgeFunctions ?? false, + }); + + if (deployErrors.length > 0) { + warningMessage += `Some Supabase functions failed to deploy after revert: ${deployErrors.join(", ")}`; + logger.warn(warningMessage); + // Note: We don't fail the revert operation if function deployment fails + // The code has been successfully reverted, but functions may be out of sync + } else { + logger.info( + `Successfully re-deployed all Supabase edge functions for app ${appId}`, + ); + } + } catch (error) { + warningMessage += `Error re-deploying Supabase edge functions after revert: ${error}`; + logger.warn(warningMessage); + // Continue with the revert operation even if function deployment fails + } + } + await syncCloudSandboxSnapshotBestEffort(appId); + + return { successMessage, warningMessage }; +} + export function registerVersionHandlers() { createTypedHandler(versionContracts.listVersions, async (_, params) => { const { appId } = params; @@ -389,8 +589,6 @@ export function registerVersionHandlers() { const { appId, previousVersionId, currentChatMessageId, targetBranchName } = params; return withLock(appId, async () => { - let successMessage = "Restored version"; - let warningMessage = ""; const app = await db.query.apps.findFirst({ where: eq(apps.id, appId), }); @@ -400,39 +598,15 @@ export function registerVersionHandlers() { } const appPath = getDyadAppPath(app.path); - const currentBranch = await gitCurrentBranch({ path: appPath }); - const revertRef = currentBranch ?? targetBranchName ?? "HEAD"; - // Get the current commit hash before reverting - const currentCommitHash = await getCurrentCommitHash({ - path: appPath, - ref: revertRef, - }); - await gitCheckout({ - path: appPath, - ref: revertRef, + const { successMessage, warningMessage } = await revertCodebaseToVersion({ + appId, + app, + appPath, + previousVersionId, + targetBranchName, }); - if (app.neonProjectId && app.neonDevelopmentBranchId) { - // We are going to add a new commit on top, so let's store - // the current timestamp at the current version. - await storeDbTimestampAtCurrentVersion({ - appId, - }); - } - - await gitStageToRevert({ - path: appPath, - targetOid: previousVersionId, - }); - const isClean = await isGitStatusClean({ path: appPath }); - if (!isClean) { - await gitCommit({ - path: appPath, - message: `Reverted all changes back to version ${previousVersionId}`, - }); - } - // Delete messages based on currentChatMessageId if provided, otherwise use commit hash lookup if (currentChatMessageId) { // Delete all messages including and after the specified message @@ -494,126 +668,271 @@ export function registerVersionHandlers() { } } - if (app.neonProjectId && app.neonDevelopmentBranchId) { - const version = await db.query.versions.findFirst({ - where: and( - eq(versions.appId, appId), - eq(versions.commitHash, previousVersionId), - ), + if (warningMessage) { + return { warningMessage }; + } + return { successMessage }; + }); + }); + + createTypedHandler( + versionContracts.restoreToMessageVersion, + async (_, params) => { + const { appId, chatId, messageId, restoreCodebase = true } = params; + return withLock(appId, async () => { + const app = await db.query.apps.findFirst({ + where: eq(apps.id, appId), }); - if (version && version.neonDbTimestamp) { - try { - const preserveBranchName = `preserve_${currentCommitHash}-${Date.now()}`; - const neonClient = await getNeonClient(); - const response = await retryOnLocked( - () => - neonClient.restoreProjectBranch( - app.neonProjectId!, - app.neonDevelopmentBranchId!, - { - source_branch_id: app.neonDevelopmentBranchId!, - source_timestamp: version.neonDbTimestamp!, - preserve_under_name: preserveBranchName, - }, - ), - `Restore development branch ${app.neonDevelopmentBranchId} for app ${appId}`, - ); - // Update all versions which have a newer DB timestamp than the version we're restoring to - // and remove their DB timestamp. - await db - .update(versions) - .set({ neonDbTimestamp: null }) - .where( - and( - eq(versions.appId, appId), - gt(versions.neonDbTimestamp, version.neonDbTimestamp), - ), - ); + if (!app) { + throw new DyadError("App not found", DyadErrorKind.NotFound); + } + const appPath = getDyadAppPath(app.path); - const preserveBranchId = response.data.branch.parent_id; - if (!preserveBranchId) { - throw new DyadError( - "Preserve branch ID not found", - DyadErrorKind.NotFound, - ); - } - logger.info( - `Deleting preserve branch ${preserveBranchId} for app ${appId}`, - ); - try { - // Intentionally do not await this because it's not - // critical for the restore operation, it's to clean up branches - // so the user doesn't hit the branch limit later. - retryOnLocked( - () => - neonClient.deleteProjectBranch( - app.neonProjectId!, - preserveBranchId, - ), - `Delete preserve branch ${preserveBranchId} for app ${appId}`, - { retryBranchWithChildError: true }, - ); - } catch (error) { - const errorMessage = getNeonErrorMessage(error); - logger.error("Error in deleteProjectBranch:", errorMessage); + const chat = await db.query.chats.findFirst({ + where: eq(chats.id, chatId), + with: { + messages: { + orderBy: (messages, { asc }) => [ + asc(messages.createdAt), + asc(messages.id), + ], + }, + }, + }); + if (!chat) { + throw new DyadError("Chat not found", DyadErrorKind.NotFound); + } + // Defense in depth: make sure the chat actually belongs to this app so + // a mismatched (appId, chatId) from the renderer can't create a chat + // under the wrong app or revert to a commit from another app's repo. + if (chat.appId !== appId) { + throw new DyadError( + "Chat does not belong to this app", + DyadErrorKind.Validation, + ); + } + + const targetIndex = chat.messages.findIndex((m) => m.id === messageId); + if (targetIndex === -1) { + throw new DyadError("Message not found", DyadErrorKind.NotFound); + } + + const messagesBefore = chat.messages.slice(0, targetIndex); + + // Restoring to the very first message is allowed: "version 1" is the + // commit that existed before the first message's changes were applied, + // so we restore to that version and fork an empty chat (no prior + // messages to copy). The empty chat starts from the restored version. + + // Determine the version that existed right before the target message. + // Primary signal: the assistant message that responded to the target + // user message stores `sourceCommitHash` = the commit current when that + // turn started (i.e. before its code changes). Walk forward past any + // intervening user messages (e.g. consecutive prompts or compaction + // summaries) to find that responding assistant message. + let targetCommitHash: string | null = null; + for (let i = targetIndex + 1; i < chat.messages.length; i++) { + const m = chat.messages[i]; + if (m.role === "assistant") { + targetCommitHash = m.sourceCommitHash ?? null; + break; + } + } + // Fallback: the assistant message preceding the target user message + // stores `commitHash` = the version that existed when it finished, which + // is the state right before the target message was sent. + if (!targetCommitHash) { + for (let i = targetIndex - 1; i >= 0; i--) { + const m = chat.messages[i]; + if (m.role === "assistant" && m.commitHash) { + targetCommitHash = m.commitHash; + break; } - successMessage = - "Successfully restored to version (including database)"; - } catch (error) { - logger.error( - "Error restoring Neon development branch during revert:", - getNeonErrorMessage(error), - ); - // Do not throw: the code has already been reverted, so we keep the - // current database, warn the user, and finish switching the - // postgres branch. This commonly happens when the picked version is - // older than Neon's retention window. - warningMessage = getDatabaseRestoreWarning(error); } } - await switchPostgresToDevelopmentBranch({ - neonProjectId: app.neonProjectId, - neonDevelopmentBranchId: app.neonDevelopmentBranchId, - appPath: app.path, - }); - } - // Re-deploy all Supabase edge functions after reverting - if (app.supabaseProjectId) { - try { - logger.info( - `Re-deploying all Supabase edge functions for app ${appId} after revert`, - ); - const settings = readSettings(); - const deployErrors = await deployAllSupabaseFunctions({ + // Final fallback: the commit the chat started from. + if (!targetCommitHash) { + targetCommitHash = chat.initialCommitHash ?? null; + } + + // When the user chose to also restore the codebase, we need a concrete + // version to revert to. Revert the codebase first: if this throws (e.g. + // a Git or Neon error), we bail out before touching the database so we + // don't leave an orphaned, partially-created chat behind. A + // `warningMessage` still means the codebase was reverted (only a + // secondary step failed), so we go on to create the new chat in that + // case. When the user only forks the chat, we skip the revert entirely. + let successMessage = "Forked the chat into a new chat."; + let warningMessage = ""; + + if (restoreCodebase) { + if (!targetCommitHash) { + // No version could be determined, so we don't create a new chat. + // Omit `newChatId` so the renderer stays on the current chat instead + // of "navigating" to the same one (which would look like a no-op). + return { + warningMessage: + "Could not determine a version to restore to for this message.", + }; + } + // The hash was resolved from stored DB fields, so a garbage-collected + // or stale value would otherwise surface as an opaque git error from + // `gitStageToRevert`. Validate it exists first (mirroring the + // `revertVersion` flow) and return a user-friendly warning instead of + // creating the forked chat if it doesn't. + try { + await assertVersionExists({ + appPath, + versionId: targetCommitHash, + }); + } catch (error) { + if ( + error instanceof DyadError && + error.kind === DyadErrorKind.NotFound + ) { + return { + warningMessage: + "Could not restore the codebase because the target version no longer exists in the repository.", + }; + } + throw error; + } + const result = await revertCodebaseToVersion({ + appId, + app, appPath, - supabaseProjectId: app.supabaseProjectId, - supabaseOrganizationSlug: app.supabaseOrganizationSlug ?? null, - skipPruneEdgeFunctions: settings.skipPruneEdgeFunctions ?? false, + previousVersionId: targetCommitHash, }); + successMessage = result.successMessage; + warningMessage = result.warningMessage; + } - if (deployErrors.length > 0) { - warningMessage += `Some Supabase functions failed to deploy after revert: ${deployErrors.join(", ")}`; - logger.warn(warningMessage); - // Note: We don't fail the revert operation if function deployment fails - // The code has been successfully reverted, but functions may be out of sync - } else { - logger.info( - `Successfully re-deployed all Supabase edge functions for app ${appId}`, + // Carry over the original chat's title (with a suffix) so the forked + // chat is identifiable in the sidebar instead of showing up as another + // indistinguishable "untitled" entry after one or more restores. When + // the original is untitled we still apply a bare "(restored)" label so + // the fork isn't rendered as a generic "New Chat" entry. Strip any + // existing "(restored)" suffix first so repeated restores don't pile up + // (e.g. "My App (restored) (restored)") and clutter the sidebar. + const baseTitle = chat.title?.replace(/ \(restored\)$/, "") ?? null; + const restoredTitle = baseTitle + ? `${baseTitle} (restored)` + : "(restored)"; + + // Anchor the forked chat to the version it actually starts from. When we + // restored the codebase, that's the target version. When we only forked + // the chat (codebase left untouched), the new chat starts from the + // current tree, so use the current commit instead of the historical + // target — otherwise "changes since chat started" would incorrectly span + // everything between the old target and HEAD. + const forkInitialCommitHash = restoreCodebase + ? targetCommitHash + : await getCurrentCommitHash({ path: appPath }).catch( + () => targetCommitHash, ); + + // Compile-time guard so a new column added to the `messages` schema in + // db/schema.ts isn't silently dropped when copying messages into the + // forked chat. Every column must be classified below as either copied + // (in the `.map` further down) or intentionally excluded; adding a + // column to the schema without listing it here becomes a type error. + type CopiedMessageColumn = + | "role" + | "content" + | "approvalState" + | "sourceCommitHash" + | "commitHash" + | "requestId" + | "maxTokensUsed" + | "model" + | "aiMessagesJson" + | "isCompactionSummary" + | "createdAt"; + // Deliberately not copied from the source message: + // - `id`: autoIncrement primary key, generated per inserted row. + // - `chatId`: set to the newly created chat below. + // - `usingFreeAgentModeQuota`: reset to false (see note below). + type ExcludedMessageColumn = + | "id" + | "chatId" + | "usingFreeAgentModeQuota"; + // If a column is neither copied nor excluded, this `Exclude` is no + // longer `never` and the assignment fails to compile, flagging the + // unclassified column. + const _assertAllMessageColumnsHandled: Exclude< + keyof typeof messages.$inferSelect, + CopiedMessageColumn | ExcludedMessageColumn + > extends never + ? true + : never = true; + void _assertAllMessageColumnsHandled; + + // Create the new chat pointing at that version and copy over the earlier + // messages atomically. We insert directly (instead of using the + // createChat handler) so `initialCommitHash` is the intended version + // rather than whatever the createChat handler would capture. Wrapping + // both inserts in a transaction ensures we never leave behind an + // orphaned, empty "(restored)" chat if the messages insert fails after + // the chat insert. better-sqlite3 transactions are synchronous, so the + // callback uses the sync query API (`.get()`/`.run()`) rather than + // `await`. + const newChat = db.transaction((tx) => { + const createdChat = tx + .insert(chats) + .values({ + appId, + title: restoredTitle, + chatMode: chat.chatMode, + initialCommitHash: forkInitialCommitHash, + }) + .returning() + .get(); + + // Copy all messages that came before the target message into the new + // chat, preserving their fields and ordering. + if (messagesBefore.length > 0) { + tx.insert(messages) + .values( + // IMPORTANT: keep this field list in sync with the `messages` + // table schema in db/schema.ts. New columns are NOT copied + // automatically — add them here (or make a conscious decision to + // omit them, like `usingFreeAgentModeQuota` below) when the + // schema changes. The `_assertAllMessageColumnsHandled` guard + // above enforces this classification at compile time. + messagesBefore.map((m) => ({ + chatId: createdChat.id, + role: m.role, + content: m.content, + approvalState: m.approvalState, + sourceCommitHash: m.sourceCommitHash, + commitHash: m.commitHash, + requestId: m.requestId, + maxTokensUsed: m.maxTokensUsed, + model: m.model, + aiMessagesJson: m.aiMessagesJson, + // Don't carry over the free-agent quota flag. The copied + // messages represent already-completed turns; preserving the + // flag would make getFreeAgentQuotaStatus (which counts every + // row globally) double-count those past requests and could + // exhaust a non-Pro user's quota without any new model call. + usingFreeAgentModeQuota: false, + isCompactionSummary: m.isCompactionSummary, + createdAt: m.createdAt, + })), + ) + .run(); } - } catch (error) { - warningMessage += `Error re-deploying Supabase edge functions after revert: ${error}`; - logger.warn(warningMessage); - // Continue with the revert operation even if function deployment fails + + return createdChat; + }); + + if (warningMessage) { + return { newChatId: newChat.id, warningMessage }; } - } - await syncCloudSandboxSnapshotBestEffort(appId); - if (warningMessage) { - return { warningMessage }; - } - return { successMessage }; - }); - }); + return { newChatId: newChat.id, successMessage }; + }); + }, + ); createTypedHandler(versionContracts.checkoutVersion, async (_, params) => { const { appId, versionId: gitRef } = params; diff --git a/src/ipc/types/index.ts b/src/ipc/types/index.ts index 7ced2562ab..e632655d27 100644 --- a/src/ipc/types/index.ts +++ b/src/ipc/types/index.ts @@ -255,6 +255,8 @@ export type { RevertVersionResponse, VersionChangedFile, CheckoutVersionResponse, + RestoreToMessageParams, + RestoreToMessageResponse, } from "./version"; // Language model types diff --git a/src/ipc/types/version.ts b/src/ipc/types/version.ts index ebd48e4300..fc4a25633f 100644 --- a/src/ipc/types/version.ts +++ b/src/ipc/types/version.ts @@ -112,6 +112,38 @@ export const VersionMetadataResultSchema = z.object({ note: z.string().nullable(), }); +export const RestoreToMessageParamsSchema = z.object({ + appId: z.number(), + // Constrain to positive integers: these are autoIncrement primary keys, so + // negative, zero, and float values are always invalid and should fail fast + // with a descriptive error rather than silently matching no row. + chatId: z.number().int().positive(), + messageId: z.number().int().positive(), + // When true (the default), the codebase (and database, if applicable) is + // reverted to the version before this message in addition to forking the + // chat. When false, only the chat is forked and the codebase is left as-is. + restoreCodebase: z.boolean().optional(), +}); + +export type RestoreToMessageParams = z.infer< + typeof RestoreToMessageParamsSchema +>; + +export const RestoreToMessageResponseSchema = z.union([ + z.object({ newChatId: z.number(), successMessage: z.string() }), + z.object({ newChatId: z.number(), warningMessage: z.string() }), + // No version could be determined, so no new chat was created. The renderer + // should stay on the current chat (there is no `newChatId` to navigate to). + // `.strict()` so a malformed response that still carries a `newChatId` is + // rejected rather than silently stripped down to this "no new chat" branch, + // which would strand the renderer on the wrong chat. + z.object({ warningMessage: z.string() }).strict(), +]); + +export type RestoreToMessageResponse = z.infer< + typeof RestoreToMessageResponseSchema +>; + // ============================================================================= // Version Contracts // ============================================================================= @@ -153,6 +185,12 @@ export const versionContracts = { output: VersionMetadataResultSchema, }), + restoreToMessageVersion: defineContract({ + channel: "restore-to-message-version", + input: RestoreToMessageParamsSchema, + output: RestoreToMessageResponseSchema, + }), + getCurrentBranch: defineContract({ channel: "get-current-branch", input: z.object({ appId: z.number() }),