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 ? (
+ <>
+