diff --git a/desktop/src-tauri/src/commands/project_git_workflow.rs b/desktop/src-tauri/src/commands/project_git_workflow.rs
index 8763946e4c..1424094746 100644
--- a/desktop/src-tauri/src/commands/project_git_workflow.rs
+++ b/desktop/src-tauri/src/commands/project_git_workflow.rs
@@ -32,6 +32,68 @@ pub struct ProjectRepoMergeResult {
     pub status_publication_error: Option<String>,
 }
 
+/// Machine-readable recovery metadata for a failed pull-request merge.
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ProjectPullRequestMergeRecovery {
+    action: String,
+    target_branch: String,
+    source_branch: String,
+}
+
+/// Structured pull-request merge failure returned across the Tauri boundary.
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ProjectPullRequestMergeError {
+    code: String,
+    message: String,
+    recovery: Option<ProjectPullRequestMergeRecovery>,
+}
+
+impl ProjectPullRequestMergeError {
+    fn new(code: &str, message: impl Into<String>) -> Self {
+        Self {
+            code: code.to_string(),
+            message: message.into(),
+            recovery: None,
+        }
+    }
+
+    fn conflict(target_branch: String, source_branch: String) -> Self {
+        Self {
+            code: "merge_conflict".to_string(),
+            message: "Pull request has merge conflicts.".to_string(),
+            recovery: Some(ProjectPullRequestMergeRecovery {
+                action: "open_terminal".to_string(),
+                target_branch,
+                source_branch,
+            }),
+        }
+    }
+}
+
+impl From<String> for ProjectPullRequestMergeError {
+    fn from(message: String) -> Self {
+        Self::new("merge_failed", message)
+    }
+}
+
+fn classify_merge_error(
+    message: String,
+    has_conflicts: bool,
+    target_branch: &str,
+    source_branch: &str,
+) -> ProjectPullRequestMergeError {
+    if has_conflicts {
+        ProjectPullRequestMergeError::conflict(target_branch.to_string(), source_branch.to_string())
+    } else {
+        ProjectPullRequestMergeError::new(
+            "merge_failed",
+            format!("Pull request merge failed: {message}"),
+        )
+    }
+}
+
 struct ProjectRepoMergeGitResult {
     message: String,
     merge_commit: String,
@@ -403,7 +465,7 @@ pub async fn merge_project_pull_request(
     input: ProjectPullRequestMergeInput,
     app: AppHandle,
     state: State<'_, AppState>,
-) -> Result<ProjectRepoMergeResult, String> {
+) -> Result<ProjectRepoMergeResult, ProjectPullRequestMergeError> {
     let ProjectPullRequestMergeInput {
         target_clone_url,
         source_clone_url,
@@ -420,10 +482,12 @@ pub async fn merge_project_pull_request(
     validate_workspace_clone_url(&source_clone_url, &state)?;
     let target_owner = target_owner.trim().to_ascii_lowercase();
     if target_owner.len() != 64 || !target_owner.chars().all(|c| c.is_ascii_hexdigit()) {
-        return Err("Invalid target repository owner.".to_string());
+        return Err("Invalid target repository owner.".to_string().into());
     }
     if clone_url_owner(&target_clone_url).as_deref() != Some(target_owner.as_str()) {
-        return Err("Target clone URL does not match the repository owner.".to_string());
+        return Err("Target clone URL does not match the repository owner."
+            .to_string()
+            .into());
     }
     let owner_identity = project_owner_identity(&app, &state, &target_owner)?;
     let merger_pubkey = owner_identity.keys.public_key().to_hex();
@@ -432,7 +496,9 @@ pub async fn merge_project_pull_request(
     let source_branch = normalize_branch_option(Some(&source_branch))
         .ok_or_else(|| "Invalid source branch.".to_string())?;
     if target_branch == source_branch && same_repository(&target_clone_url, &source_clone_url) {
-        return Err("Source and target branches must be different.".to_string());
+        return Err("Source and target branches must be different."
+            .to_string()
+            .into());
     }
     let expected_commit = normalize_commit(&expected_commit)
         .ok_or_else(|| "Invalid pull request commit.".to_string())?;
@@ -444,87 +510,109 @@ pub async fn merge_project_pull_request(
     )?;
     let auth = build_git_auth_config_for_keys(&owner_identity.keys)?;
 
-    let git_result = tauri::async_runtime::spawn_blocking(move || {
-        let temp_dir = tempfile::tempdir().map_err(|error| format!("create temp dir: {error}"))?;
-        let repo_dir = temp_dir.path().join("repo");
-        let repo_path = repo_dir
-            .to_str()
-            .ok_or_else(|| "temporary repository path is not UTF-8".to_string())?;
-        run_git(
-            &[
-                "clone",
-                "--filter=blob:none",
-                "--no-tags",
-                "--branch",
-                target_branch.as_str(),
-                "--single-branch",
-                "--end-of-options",
-                target_clone_url.as_str(),
-                repo_path,
-            ],
-            None,
-            &auth,
-        )?;
-        run_git(
-            &[
-                "fetch",
-                "--quiet",
-                "--end-of-options",
-                source_clone_url.as_str(),
-                source_branch.as_str(),
-            ],
-            Some(&repo_dir),
-            &auth,
-        )?;
-        let source_head = run_git(&["rev-parse", "FETCH_HEAD"], Some(&repo_dir), &auth)
-            .ok()
-            .and_then(|output| first_output_line(&output))
-            .ok_or_else(|| "Could not resolve the pull request branch.".to_string())?;
-        if source_head.to_ascii_lowercase() != expected_commit {
-            return Err(
-                "The pull request branch changed. Refresh the pull request before merging."
-                    .to_string(),
-            );
-        }
-
-        let merge_email = format!("{merger_pubkey}@users.noreply.buzz");
-        run_git(
-            &[
-                "-c",
-                "user.name=Buzz User",
-                "-c",
-                format!("user.email={merge_email}").as_str(),
-                "merge",
-                "--no-edit",
-                "--end-of-options",
-                expected_commit.as_str(),
-            ],
-            Some(&repo_dir),
-            &auth,
-        )
-        .map_err(|error| format!("Pull request cannot be merged cleanly: {error}"))?;
-        let merge_commit = run_git(&["rev-parse", "HEAD"], Some(&repo_dir), &auth)
-            .ok()
-            .and_then(|output| first_output_line(&output))
-            .ok_or_else(|| "Could not resolve the merge commit.".to_string())?;
-        run_git(
-            &[
-                "push",
-                "--end-of-options",
-                "origin",
-                format!("HEAD:{target_branch}").as_str(),
-            ],
-            Some(&repo_dir),
-            &auth,
-        )?;
+    let git_result = tauri::async_runtime::spawn_blocking(
+        move || -> Result<ProjectRepoMergeGitResult, ProjectPullRequestMergeError> {
+            let temp_dir =
+                tempfile::tempdir().map_err(|error| format!("create temp dir: {error}"))?;
+            let repo_dir = temp_dir.path().join("repo");
+            let repo_path = repo_dir
+                .to_str()
+                .ok_or_else(|| "temporary repository path is not UTF-8".to_string())?;
+            run_git(
+                &[
+                    "clone",
+                    "--filter=blob:none",
+                    "--no-tags",
+                    "--branch",
+                    target_branch.as_str(),
+                    "--single-branch",
+                    "--end-of-options",
+                    target_clone_url.as_str(),
+                    repo_path,
+                ],
+                None,
+                &auth,
+            )?;
+            run_git(
+                &[
+                    "fetch",
+                    "--quiet",
+                    "--end-of-options",
+                    source_clone_url.as_str(),
+                    source_branch.as_str(),
+                ],
+                Some(&repo_dir),
+                &auth,
+            )?;
+            let source_head = run_git(&["rev-parse", "FETCH_HEAD"], Some(&repo_dir), &auth)
+                .ok()
+                .and_then(|output| first_output_line(&output))
+                .ok_or_else(|| "Could not resolve the pull request branch.".to_string())?;
+            if source_head.to_ascii_lowercase() != expected_commit {
+                return Err(ProjectPullRequestMergeError::new(
+                    "branch_changed",
+                    "The pull request branch changed. Refresh the pull request before merging."
+                        .to_string(),
+                ));
+            }
 
-        Ok(ProjectRepoMergeGitResult {
-            message: format!("Merged {source_branch} into {target_branch}."),
-            merge_commit,
-        })
-    })
+            let merge_email = format!("{merger_pubkey}@users.noreply.buzz");
+            let merge_result = run_git(
+                &[
+                    "-c",
+                    "user.name=Buzz User",
+                    "-c",
+                    format!("user.email={merge_email}").as_str(),
+                    "merge",
+                    "--no-edit",
+                    "--end-of-options",
+                    expected_commit.as_str(),
+                ],
+                Some(&repo_dir),
+                &auth,
+            );
+            if let Err(error) = merge_result {
+                let has_conflicts = run_git(
+                    &["diff", "--name-only", "--diff-filter=U"],
+                    Some(&repo_dir),
+                    &auth,
+                )
+                .is_ok_and(|output| !output.trim().is_empty());
+                return Err(classify_merge_error(
+                    error,
+                    has_conflicts,
+                    &target_branch,
+                    &source_branch,
+                ));
+            }
+            let merge_commit = run_git(&["rev-parse", "HEAD"], Some(&repo_dir), &auth)
+                .ok()
+                .and_then(|output| first_output_line(&output))
+                .ok_or_else(|| "Could not resolve the merge commit.".to_string())?;
+            run_git(
+                &[
+                    "push",
+                    "--end-of-options",
+                    "origin",
+                    format!("HEAD:{target_branch}").as_str(),
+                ],
+                Some(&repo_dir),
+                &auth,
+            )?;
+
+            Ok(ProjectRepoMergeGitResult {
+                message: format!("Merged {source_branch} into {target_branch}."),
+                merge_commit,
+            })
+        },
+    )
     .await
-    .map_err(|error| format!("pull request merge task failed: {error}"))??;
+    .map_err(|error| {
+        ProjectPullRequestMergeError::new(
+            "merge_task_failed",
+            format!("pull request merge task failed: {error}"),
+        )
+    })??;
     let status_event = build_merged_status_event(
         &owner_identity.keys,
         &repo_address,
@@ -554,8 +642,9 @@ pub async fn merge_project_pull_request(
 #[cfg(test)]
 mod tests {
     use super::{
-        build_merged_status_event, build_review_request_event, normalize_commit, same_repository,
-        validate_merge_status_metadata,
+        build_merged_status_event, build_review_request_event, classify_merge_error,
+        normalize_commit, same_repository, validate_merge_status_metadata,
+        ProjectPullRequestMergeError,
     };
     use nostr::{Event, JsonUtil, Keys, Timestamp};
 
@@ -583,6 +672,51 @@ mod tests {
         ));
     }
 
+    #[test]
+    fn merge_conflict_error_has_stable_recovery_metadata() {
+        let error =
+            ProjectPullRequestMergeError::conflict("main".to_string(), "feature/demo".to_string());
+
+        assert_eq!(error.code, "merge_conflict");
+        assert_eq!(error.message, "Pull request has merge conflicts.");
+        let recovery = error.recovery.expect("conflict recovery");
+        assert_eq!(recovery.action, "open_terminal");
+        assert_eq!(recovery.target_branch, "main");
+        assert_eq!(recovery.source_branch, "feature/demo");
+    }
+
+    #[test]
+    fn merge_conflict_error_serializes_for_tauri_clients() {
+        let error =
+            ProjectPullRequestMergeError::conflict("main".to_string(), "feature/demo".to_string());
+        let value = serde_json::to_value(error).expect("serialize merge conflict");
+
+        assert_eq!(value["code"], "merge_conflict");
+        assert_eq!(value["recovery"]["targetBranch"], "main");
+        assert_eq!(value["recovery"]["sourceBranch"], "feature/demo");
+    }
+
+    #[test]
+    fn merge_error_classification_only_recovers_conflicts() {
+        let conflict = classify_merge_error(
+            "CONFLICT (content): Merge conflict in src/main.rs".to_string(),
+            true,
+            "main",
+            "feature/demo",
+        );
+        assert_eq!(conflict.code, "merge_conflict");
+        assert!(conflict.recovery.is_some());
+
+        let other = classify_merge_error(
+            "fatal: refusing to merge unrelated histories".to_string(),
+            false,
+            "main",
+            "feature/demo",
+        );
+        assert_eq!(other.code, "merge_failed");
+        assert!(other.recovery.is_none());
+    }
+
     #[test]
     fn merged_status_is_signed_by_repository_owner() {
         let keys = Keys::generate();
diff --git a/desktop/src-tauri/src/commands/project_terminal.rs b/desktop/src-tauri/src/commands/project_terminal.rs
index 24bc138407..31dbc74c6d 100644
--- a/desktop/src-tauri/src/commands/project_terminal.rs
+++ b/desktop/src-tauri/src/commands/project_terminal.rs
@@ -1,13 +1,15 @@
 //! Opens an OS terminal window at a project's local git checkout, cloning
 //! the repository from the relay first when no local checkout exists.
 
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
 use std::process::Command;
 use tauri::State;
 
 use crate::app_state::AppState;
 
-use super::project_git_exec::{build_git_auth_config, validate_workspace_clone_url};
+use super::project_git::{first_output_line, normalize_branch_option};
+use super::project_git_diff::clean_commit;
+use super::project_git_exec::{build_git_auth_config, run_git, validate_workspace_clone_url};
 use super::project_git_workflow::clone_project_repository_blocking;
 use super::project_repo_paths::find_local_repo_dir;
 
@@ -19,6 +21,37 @@ pub struct ProjectTerminalResult {
     pub cloned: bool,
 }
 
+/// Inputs for preparing an authenticated local merge-conflict recovery.
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ProjectMergeRecoveryTerminalInput {
+    repos_dir: Option<String>,
+    project_dtag: String,
+    target_clone_url: String,
+    source_clone_url: String,
+    target_branch: String,
+    source_branch: String,
+    expected_commit: String,
+}
+
+/// Local checkout and ref prepared for terminal-based conflict resolution.
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ProjectMergeRecoveryTerminalResult {
+    path: String,
+    cloned: bool,
+    recovery_ref: String,
+    target_ref: String,
+}
+
+fn merge_recovery_ref(expected_commit: &str) -> String {
+    format!("refs/buzz/merge-recovery/{expected_commit}")
+}
+
+fn merge_recovery_target_ref(target_commit: &str) -> String {
+    format!("refs/buzz/merge-recovery-target/{target_commit}")
+}
+
 #[cfg(target_os = "macos")]
 fn launch_terminal_at(path: &std::path::Path) -> Result<(), String> {
     let status = Command::new("open")
@@ -122,3 +155,123 @@ pub async fn open_project_terminal(
     .await
     .map_err(|error| format!("open terminal task failed: {error}"))?
 }
+
+/// Authenticates and fetches the exact pull-request commit before opening a
+/// terminal. The user's worktree is not switched or modified.
+#[tauri::command]
+pub async fn open_project_merge_recovery_terminal(
+    input: ProjectMergeRecoveryTerminalInput,
+    state: State<'_, AppState>,
+) -> Result<ProjectMergeRecoveryTerminalResult, String> {
+    validate_workspace_clone_url(&input.target_clone_url, &state)?;
+    validate_workspace_clone_url(&input.source_clone_url, &state)?;
+    let target_branch = normalize_branch_option(Some(&input.target_branch))
+        .ok_or_else(|| "Invalid target branch.".to_string())?;
+    let source_branch = normalize_branch_option(Some(&input.source_branch))
+        .ok_or_else(|| "Invalid source branch.".to_string())?;
+    let expected_commit = clean_commit(Some(input.expected_commit.trim().to_ascii_lowercase()))
+        .ok_or_else(|| "Invalid pull request commit.".to_string())?;
+    let auth = build_git_auth_config(&state)?;
+
+    tauri::async_runtime::spawn_blocking(move || {
+        let existing_dir = find_local_repo_dir(
+            input.repos_dir.as_deref(),
+            &input.project_dtag,
+            Some(&input.target_clone_url),
+        )
+        .ok()
+        .flatten();
+        let (repo_dir, cloned) = if let Some(repo_dir) = existing_dir {
+            (repo_dir, false)
+        } else {
+            let clone_result = clone_project_repository_blocking(
+                input.repos_dir.as_deref(),
+                &input.project_dtag,
+                &input.target_clone_url,
+                Some(&target_branch),
+                &auth,
+            )?;
+            (std::path::PathBuf::from(clone_result.path), true)
+        };
+
+        run_git(
+            &[
+                "fetch",
+                "--quiet",
+                "--no-tags",
+                "--end-of-options",
+                input.target_clone_url.as_str(),
+                target_branch.as_str(),
+            ],
+            Some(&repo_dir),
+            &auth,
+        )?;
+        let target_head = run_git(&["rev-parse", "FETCH_HEAD"], Some(&repo_dir), &auth)
+            .ok()
+            .and_then(|output| first_output_line(&output))
+            .ok_or_else(|| "Could not resolve the target branch.".to_string())?;
+        let target_ref = merge_recovery_target_ref(&target_head);
+        run_git(
+            &["update-ref", target_ref.as_str(), target_head.as_str()],
+            Some(&repo_dir),
+            &auth,
+        )?;
+
+        run_git(
+            &[
+                "fetch",
+                "--quiet",
+                "--no-tags",
+                "--end-of-options",
+                input.source_clone_url.as_str(),
+                source_branch.as_str(),
+            ],
+            Some(&repo_dir),
+            &auth,
+        )?;
+        let source_head = run_git(&["rev-parse", "FETCH_HEAD"], Some(&repo_dir), &auth)
+            .ok()
+            .and_then(|output| first_output_line(&output))
+            .ok_or_else(|| "Could not resolve the pull request branch.".to_string())?;
+        if source_head.to_ascii_lowercase() != expected_commit {
+            return Err(
+                "The pull request branch changed. Refresh the pull request before resolving conflicts."
+                    .to_string(),
+            );
+        }
+
+        let recovery_ref = merge_recovery_ref(&expected_commit);
+        run_git(
+            &["update-ref", recovery_ref.as_str(), expected_commit.as_str()],
+            Some(&repo_dir),
+            &auth,
+        )?;
+        launch_terminal_at(&repo_dir)?;
+        Ok(ProjectMergeRecoveryTerminalResult {
+            path: repo_dir.display().to_string(),
+            cloned,
+            recovery_ref,
+            target_ref,
+        })
+    })
+    .await
+    .map_err(|error| format!("open merge recovery terminal task failed: {error}"))?
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{merge_recovery_ref, merge_recovery_target_ref};
+
+    #[test]
+    fn recovery_ref_is_namespaced_by_verified_commit() {
+        let commit = "a".repeat(40);
+        assert_eq!(
+            merge_recovery_ref(&commit),
+            format!("refs/buzz/merge-recovery/{commit}"),
+        );
+        assert_eq!(
+            merge_recovery_target_ref(&commit),
+            format!("refs/buzz/merge-recovery-target/{commit}"),
+        );
+    }
+}
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 174722250d..6e4b2a81c8 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -679,6 +679,7 @@ pub fn run() {
             publish_project_pull_request_merged_status,
             merge_project_pull_request,
             open_project_terminal,
+            open_project_merge_recovery_terminal,
             search_users,
             get_presence,
             get_os_idle_seconds,
diff --git a/desktop/src/features/projects/projectPullRequestConflictRecovery.test.mjs b/desktop/src/features/projects/projectPullRequestConflictRecovery.test.mjs
new file mode 100644
index 0000000000..6956af2845
--- /dev/null
+++ b/desktop/src/features/projects/projectPullRequestConflictRecovery.test.mjs
@@ -0,0 +1,38 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { projectPullRequestConflictCommands } from "./projectPullRequestConflictRecovery.ts";
+
+test("builds copyable commands without executing merge recovery", () => {
+  assert.deepEqual(
+    projectPullRequestConflictCommands({
+      recoveryRef: `refs/buzz/merge-recovery/${"a".repeat(40)}`,
+      targetBranch: "main",
+      targetRef: `refs/buzz/merge-recovery-target/${"b".repeat(40)}`,
+    }),
+    [
+      'test -z "$(git status --porcelain=v1)" &&',
+      `(git switch 'main' || git switch --create 'main' 'refs/buzz/merge-recovery-target/${"b".repeat(40)}') &&`,
+      `git merge-base --is-ancestor HEAD 'refs/buzz/merge-recovery-target/${"b".repeat(40)}' &&`,
+      `git merge --ff-only 'refs/buzz/merge-recovery-target/${"b".repeat(40)}' &&`,
+      `git merge 'refs/buzz/merge-recovery/${"a".repeat(40)}'`,
+    ],
+  );
+});
+
+test("quotes recovery values as inert shell arguments", () => {
+  const commands = projectPullRequestConflictCommands({
+    recoveryRef: `refs/buzz/merge-recovery/${"b".repeat(40)}`,
+    targetBranch: "release candidate",
+    targetRef: `refs/buzz/merge-recovery-target/${"c".repeat(40)}`,
+  });
+
+  assert.equal(
+    commands[1],
+    `(git switch 'release candidate' || git switch --create 'release candidate' 'refs/buzz/merge-recovery-target/${"c".repeat(40)}') &&`,
+  );
+  assert.equal(
+    commands[4],
+    `git merge 'refs/buzz/merge-recovery/${"b".repeat(40)}'`,
+  );
+});
diff --git a/desktop/src/features/projects/projectPullRequestConflictRecovery.ts b/desktop/src/features/projects/projectPullRequestConflictRecovery.ts
new file mode 100644
index 0000000000..82ed2e395d
--- /dev/null
+++ b/desktop/src/features/projects/projectPullRequestConflictRecovery.ts
@@ -0,0 +1,24 @@
+function quoteShellArgument(value: string): string {
+  return `'${value.replaceAll("'", "'\\''")}'`;
+}
+
+/** Build user-visible recovery commands; callers decide whether to copy them. */
+export function projectPullRequestConflictCommands({
+  recoveryRef,
+  targetBranch,
+  targetRef,
+}: {
+  recoveryRef: string;
+  targetBranch: string;
+  targetRef: string;
+}): string[] {
+  const quotedTargetBranch = quoteShellArgument(targetBranch);
+  const quotedTargetRef = quoteShellArgument(targetRef);
+  return [
+    'test -z "$(git status --porcelain=v1)" &&',
+    `(git switch ${quotedTargetBranch} || git switch --create ${quotedTargetBranch} ${quotedTargetRef}) &&`,
+    `git merge-base --is-ancestor HEAD ${quotedTargetRef} &&`,
+    `git merge --ff-only ${quotedTargetRef} &&`,
+    `git merge ${quoteShellArgument(recoveryRef)}`,
+  ];
+}
diff --git a/desktop/src/features/projects/ui/MergePullRequestButton.tsx b/desktop/src/features/projects/ui/MergePullRequestButton.tsx
index 80f694c8de..133c41d043 100644
--- a/desktop/src/features/projects/ui/MergePullRequestButton.tsx
+++ b/desktop/src/features/projects/ui/MergePullRequestButton.tsx
@@ -1,12 +1,18 @@
-import { GitMerge } from "lucide-react";
+import { AlertTriangle, Copy, GitMerge, SquareTerminal } from "lucide-react";
 import * as React from "react";
 import { toast } from "sonner";
 
 import type { Project, ProjectPullRequest } from "@/features/projects/hooks";
+import { projectPullRequestConflictCommands } from "@/features/projects/projectPullRequestConflictRecovery";
 import {
   useMergeProjectPullRequestMutation,
   usePublishProjectPullRequestMergedMutation,
 } from "@/features/projects/pullRequestMutations";
+import {
+  ProjectPullRequestMergeError,
+  type ProjectPullRequestMergeRecovery,
+} from "@/shared/api/projectGit";
+import { copyTextToClipboard } from "@/shared/lib/clipboard";
 import {
   AlertDialog,
   AlertDialogAction,
@@ -19,36 +25,85 @@ import {
 } from "@/shared/ui/alert-dialog";
 import { Button } from "@/shared/ui/button";
 
+export type OpenMergeRecoveryTerminal = (input: {
+  expectedCommit: string;
+  sourceBranch: string;
+  sourceCloneUrl: string;
+  targetBranch: string;
+}) => Promise<{ recoveryRef: string; targetRef: string }>;
+
 export function MergePullRequestButton({
+  onOpenTerminal,
   project,
   pullRequest,
 }: {
+  onOpenTerminal?: OpenMergeRecoveryTerminal;
   project: Project;
   pullRequest: ProjectPullRequest;
 }) {
   const [confirmOpen, setConfirmOpen] = React.useState(false);
-  const [unpublishedStatusEvent, setUnpublishedStatusEvent] = React.useState<
-    string | null
-  >(null);
+  const [isPreparingRecovery, setIsPreparingRecovery] = React.useState(false);
+  const [conflictRecoveryState, setConflictRecoveryState] = React.useState<{
+    pullRequestId: string;
+    recovery: ProjectPullRequestMergeRecovery;
+  } | null>(null);
+  const [unpublishedStatusState, setUnpublishedStatusState] = React.useState<{
+    event: string;
+    pullRequestId: string;
+  } | null>(null);
+  const [preparedRecoveryState, setPreparedRecoveryState] = React.useState<{
+    pullRequestId: string;
+    recoveryRef: string;
+    targetRef: string;
+  } | null>(null);
   const mergeMutation = useMergeProjectPullRequestMutation(project);
   const publishMergedMutation =
     usePublishProjectPullRequestMergedMutation(project);
   const targetBranch = pullRequest.targetBranch ?? project.defaultBranch;
+  const conflictRecovery =
+    conflictRecoveryState?.pullRequestId === pullRequest.id
+      ? conflictRecoveryState.recovery
+      : null;
+  const unpublishedStatusEvent =
+    unpublishedStatusState?.pullRequestId === pullRequest.id
+      ? unpublishedStatusState.event
+      : null;
+  const preparedRecovery =
+    preparedRecoveryState?.pullRequestId === pullRequest.id
+      ? preparedRecoveryState
+      : null;
 
   const handleMerge = React.useCallback(async () => {
     try {
       const result = await mergeMutation.mutateAsync({ pullRequest });
       if (result.statusPublicationError) {
-        setUnpublishedStatusEvent(result.statusEvent);
+        setUnpublishedStatusState({
+          event: result.statusEvent,
+          pullRequestId: pullRequest.id,
+        });
         toast.warning(result.message, {
           description: result.statusPublicationError,
         });
       } else {
-        setUnpublishedStatusEvent(null);
+        setUnpublishedStatusState(null);
         toast.success(result.message);
       }
+      setConflictRecoveryState(null);
+      setPreparedRecoveryState(null);
       setConfirmOpen(false);
     } catch (error) {
+      if (
+        error instanceof ProjectPullRequestMergeError &&
+        error.code === "merge_conflict" &&
+        error.recovery
+      ) {
+        setConflictRecoveryState({
+          pullRequestId: pullRequest.id,
+          recovery: error.recovery,
+        });
+        setPreparedRecoveryState(null);
+        setConfirmOpen(false);
+      }
       toast.error(
         error instanceof Error
           ? error.message
@@ -57,13 +112,58 @@ export function MergePullRequestButton({
     }
   }, [mergeMutation, pullRequest]);
 
+  const recoveryCommands =
+    conflictRecovery && preparedRecovery
+      ? projectPullRequestConflictCommands({
+          recoveryRef: preparedRecovery.recoveryRef,
+          targetBranch: conflictRecovery.targetBranch,
+          targetRef: preparedRecovery.targetRef,
+        })
+      : [];
+
+  const handleOpenRecoveryTerminal = React.useCallback(async () => {
+    const sourceCloneUrl = pullRequest.cloneUrls[0] ?? project.cloneUrls[0];
+    if (!conflictRecovery || !pullRequest.commit || !sourceCloneUrl) return;
+    setIsPreparingRecovery(true);
+    try {
+      const result = await onOpenTerminal?.({
+        expectedCommit: pullRequest.commit,
+        sourceBranch: conflictRecovery.sourceBranch,
+        sourceCloneUrl,
+        targetBranch: conflictRecovery.targetBranch,
+      });
+      if (!result) return;
+      setPreparedRecoveryState({
+        pullRequestId: pullRequest.id,
+        recoveryRef: result.recoveryRef,
+        targetRef: result.targetRef,
+      });
+      toast.success("Recovery commit fetched and terminal opened.");
+    } catch (error) {
+      toast.error(
+        error instanceof Error
+          ? error.message
+          : "Failed to prepare merge recovery.",
+      );
+    } finally {
+      setIsPreparingRecovery(false);
+    }
+  }, [
+    conflictRecovery,
+    onOpenTerminal,
+    project.cloneUrls,
+    pullRequest.cloneUrls,
+    pullRequest.commit,
+    pullRequest.id,
+  ]);
+
   const handlePublishMergedStatus = React.useCallback(async () => {
     if (!unpublishedStatusEvent) return;
     try {
       await publishMergedMutation.mutateAsync({
         statusEvent: unpublishedStatusEvent,
       });
-      setUnpublishedStatusEvent(null);
+      setUnpublishedStatusState(null);
       toast.success("Published merged pull request status.");
     } catch (error) {
       toast.error(
@@ -75,55 +175,115 @@ export function MergePullRequestButton({
   }, [publishMergedMutation, unpublishedStatusEvent]);
 
   return (
-    <AlertDialog onOpenChange={setConfirmOpen} open={confirmOpen}>
-      <Button
-        className="h-8 gap-1.5 bg-purple-600 px-3.5 text-white shadow-sm hover:bg-purple-700"
-        disabled={mergeMutation.isPending || publishMergedMutation.isPending}
-        onClick={() => {
-          if (unpublishedStatusEvent) {
-            void handlePublishMergedStatus();
-          } else {
-            setConfirmOpen(true);
-          }
-        }}
-        size="xs"
-        type="button"
-      >
-        <GitMerge className="h-3.5 w-3.5" />
-        {publishMergedMutation.isPending
-          ? "Publishing…"
-          : unpublishedStatusEvent
-            ? "Publish merged status"
-            : "Merge"}
-      </Button>
-      <AlertDialogContent data-testid="merge-pull-request-confirm">
-        <AlertDialogHeader>
-          <AlertDialogTitle>Merge pull request?</AlertDialogTitle>
-          <AlertDialogDescription>
-            Merge {pullRequest.branchName} into {targetBranch} and push the
-            result to the repository. The remote will reject the operation if
-            the branch changed or conflicts.
-          </AlertDialogDescription>
-        </AlertDialogHeader>
-        <AlertDialogFooter>
-          <AlertDialogCancel disabled={mergeMutation.isPending}>
-            Cancel
-          </AlertDialogCancel>
-          <AlertDialogAction asChild>
+    <div className="contents">
+      <AlertDialog onOpenChange={setConfirmOpen} open={confirmOpen}>
+        <Button
+          className="h-8 gap-1.5 bg-purple-600 px-3.5 text-white shadow-sm hover:bg-purple-700"
+          disabled={mergeMutation.isPending || publishMergedMutation.isPending}
+          onClick={() => {
+            if (unpublishedStatusEvent) {
+              void handlePublishMergedStatus();
+            } else {
+              setConfirmOpen(true);
+            }
+          }}
+          size="xs"
+          type="button"
+        >
+          <GitMerge className="h-3.5 w-3.5" />
+          {publishMergedMutation.isPending
+            ? "Publishing…"
+            : unpublishedStatusEvent
+              ? "Publish merged status"
+              : "Merge"}
+        </Button>
+        <AlertDialogContent data-testid="merge-pull-request-confirm">
+          <AlertDialogHeader>
+            <AlertDialogTitle>Merge pull request?</AlertDialogTitle>
+            <AlertDialogDescription>
+              Merge {pullRequest.branchName} into {targetBranch} and push the
+              result to the repository. The remote will reject the operation if
+              the branch changed or conflicts.
+            </AlertDialogDescription>
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogCancel disabled={mergeMutation.isPending}>
+              Cancel
+            </AlertDialogCancel>
+            <AlertDialogAction asChild>
+              <Button
+                data-testid="merge-pull-request-confirm-button"
+                disabled={mergeMutation.isPending}
+                onClick={(event) => {
+                  event.preventDefault();
+                  void handleMerge();
+                }}
+                type="button"
+              >
+                {mergeMutation.isPending ? "Merging…" : "Merge pull request"}
+              </Button>
+            </AlertDialogAction>
+          </AlertDialogFooter>
+        </AlertDialogContent>
+      </AlertDialog>
+      {conflictRecovery ? (
+        <div
+          className="w-full basis-full space-y-2 rounded-lg border border-amber-500/35 bg-amber-500/10 p-3"
+          data-testid="merge-conflict-recovery"
+        >
+          <div className="flex items-start gap-2">
+            <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
+            <div className="min-w-0 flex-1">
+              <p className="text-sm font-medium text-foreground">
+                Resolve conflicts in your local checkout
+              </p>
+              <p className="text-xs text-muted-foreground">
+                Prepare the local checkout, then switch to{" "}
+                {conflictRecovery.targetBranch} with the commands shown. After
+                resolving and committing, push the target branch and retry the
+                merge.
+              </p>
+            </div>
+          </div>
+          {preparedRecovery ? (
+            <pre className="overflow-x-auto rounded-md bg-background/80 p-2 font-mono text-xs text-foreground">
+              {recoveryCommands.join("\n")}
+            </pre>
+          ) : (
+            <p className="rounded-md bg-background/80 p-2 text-xs text-muted-foreground">
+              Resolve in Terminal securely fetches the target and pull request
+              commits before showing copyable commands.
+            </p>
+          )}
+          <div className="flex flex-wrap gap-2">
+            <Button
+              disabled={!onOpenTerminal || isPreparingRecovery}
+              onClick={() => void handleOpenRecoveryTerminal()}
+              size="xs"
+              type="button"
+              variant="outline"
+            >
+              <SquareTerminal className="h-3.5 w-3.5" />
+              {isPreparingRecovery ? "Preparing…" : "Resolve in Terminal"}
+            </Button>
             <Button
-              data-testid="merge-pull-request-confirm-button"
-              disabled={mergeMutation.isPending}
-              onClick={(event) => {
-                event.preventDefault();
-                void handleMerge();
-              }}
+              disabled={!preparedRecovery}
+              onClick={() =>
+                copyTextToClipboard(
+                  recoveryCommands.join("\n"),
+                  "Recovery commands copied",
+                )
+              }
+              size="xs"
               type="button"
+              variant="ghost"
             >
-              {mergeMutation.isPending ? "Merging…" : "Merge pull request"}
+              <Copy className="h-3.5 w-3.5" />
+              Copy commands
             </Button>
-          </AlertDialogAction>
-        </AlertDialogFooter>
-      </AlertDialogContent>
-    </AlertDialog>
+          </div>
+        </div>
+      ) : null}
+    </div>
   );
 }
diff --git a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx
index 2c75085c40..f67d535513 100644
--- a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx
+++ b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx
@@ -42,6 +42,7 @@ import {
   profilePanelViewFromSearch,
 } from "@/features/profile/ui/UserProfilePanelUtils";
 import { useIdentityQuery } from "@/shared/api/hooks";
+import { openProjectMergeRecoveryTerminal } from "@/shared/api/projectGit";
 import { useMainInsetRef } from "@/shared/layout/MainInsetContext";
 import {
   channelChrome,
@@ -576,6 +577,26 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
       hasLocalCheckout,
     });
   }, [activeBranch, hasLocalCheckout, openTerminal, project]);
+  const handleOpenMergeRecoveryTerminal = React.useCallback(
+    async (input: {
+      expectedCommit: string;
+      sourceBranch: string;
+      sourceCloneUrl: string;
+      targetBranch: string;
+    }) => {
+      const targetCloneUrl = project?.cloneUrls[0];
+      if (!project || !targetCloneUrl) {
+        throw new Error("No project selected.");
+      }
+      return openProjectMergeRecoveryTerminal({
+        ...input,
+        projectDtag: project.dtag,
+        reposDir: activeCommunity?.reposDir,
+        targetCloneUrl,
+      });
+    },
+    [activeCommunity?.reposDir, project],
+  );
 
   if (projectQuery.isLoading) {
     return null;
@@ -850,6 +871,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
                 localSnapshotError={localRepoSnapshotQuery.error}
                 localSnapshotLoading={localRepoSnapshotQuery.isLoading}
                 onBranchChange={setSelectedBranch}
+                onOpenMergeRecoveryTerminal={handleOpenMergeRecoveryTerminal}
                 onOpenTerminal={() => {
                   void handleOpenTerminal();
                 }}
diff --git a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx
index e9ae37071d..4e2dbb2a53 100644
--- a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx
+++ b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx
@@ -32,6 +32,7 @@ import {
   ProjectFeedRowMonoCell,
 } from "./ProjectFeedRow";
 import { CopyCommitHashButton } from "./ProjectCommitCopyButton";
+import type { OpenMergeRecoveryTerminal } from "./MergePullRequestButton";
 import { OverviewRailSection } from "./ProjectOverviewPanel";
 import {
   ProfileAuthorName,
@@ -448,12 +449,14 @@ export function PullRequestMetaRail({
 function PullRequestDetail({
   mode,
   onOpenCommit,
+  onOpenTerminal,
   profiles,
   project,
   pullRequest,
 }: {
   mode: PullRequestPanelMode;
   onOpenCommit?: (commitHash: string) => void;
+  onOpenTerminal?: OpenMergeRecoveryTerminal;
   profiles?: UserProfileLookup;
   project: Project;
   pullRequest: ProjectPullRequest;
@@ -663,7 +666,11 @@ function PullRequestDetail({
             })}
           </div>
         ) : null}
-        <PullRequestReviewCard project={project} pullRequest={pullRequest} />
+        <PullRequestReviewCard
+          onOpenTerminal={onOpenTerminal}
+          project={project}
+          pullRequest={pullRequest}
+        />
         <h4 className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
           <MessageSquare className="h-3.5 w-3.5" />
           Add Your Comment
@@ -687,6 +694,7 @@ export function PullRequestsPanel({
   isLoading,
   mode = "conversation",
   onOpenCommit,
+  onOpenTerminal,
   onSelectedPullRequestIdChange,
   profiles,
   project,
@@ -697,6 +705,7 @@ export function PullRequestsPanel({
   isLoading: boolean;
   mode?: PullRequestPanelMode;
   onOpenCommit?: (commitHash: string) => void;
+  onOpenTerminal?: OpenMergeRecoveryTerminal;
   onSelectedPullRequestIdChange: (id: string | null) => void;
   profiles?: UserProfileLookup;
   project: Project;
@@ -738,6 +747,7 @@ export function PullRequestsPanel({
       <PullRequestDetail
         mode={mode}
         onOpenCommit={onOpenCommit}
+        onOpenTerminal={onOpenTerminal}
         profiles={profiles}
         project={project}
         pullRequest={selectedPullRequest}
diff --git a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx
index ddd9fab494..8fdfba194b 100644
--- a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx
+++ b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx
@@ -29,6 +29,7 @@ import type { RepoSourceHeaderControls } from "./ProjectRepositorySource";
 import { ProjectCommitDetailPanel } from "./ProjectCommitDetailPanel";
 import { ActivityPanel, ContributorsPanel } from "./ProjectDetailFeedPanels";
 import { ProjectIssuesPanel } from "./ProjectIssuesPanel";
+import type { OpenMergeRecoveryTerminal } from "./MergePullRequestButton";
 import { ProjectOverviewPanel } from "./ProjectOverviewPanel";
 import {
   PullRequestDetailHeader,
@@ -124,6 +125,7 @@ export function WorkspaceTabs({
   onSelectedPullRequestIdChange,
   onSelectedTabChange,
   onBranchChange,
+  onOpenMergeRecoveryTerminal,
   onOpenTerminal,
   snapshot,
   snapshotError,
@@ -160,6 +162,7 @@ export function WorkspaceTabs({
   /** Reports the active tab so the screen breadcrumb can mirror it. */
   onSelectedTabChange?: (tab: string) => void;
   onBranchChange: (branch: string | null) => void;
+  onOpenMergeRecoveryTerminal?: OpenMergeRecoveryTerminal;
   onOpenTerminal?: () => void;
   snapshot: ProjectRepoSnapshot | null | undefined;
   snapshotError: unknown;
@@ -260,7 +263,7 @@ export function WorkspaceTabs({
           <Button
             aria-label="Open terminal"
             className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
-            onClick={onOpenTerminal}
+            onClick={() => onOpenTerminal()}
             size="icon"
             title={terminalTitle ?? "Open terminal"}
             variant="ghost"
@@ -305,6 +308,7 @@ export function WorkspaceTabs({
                     isLoading={pullRequestsLoading}
                     mode={mode}
                     onOpenCommit={onSelectedCommitHashChange}
+                    onOpenTerminal={onOpenMergeRecoveryTerminal}
                     onSelectedPullRequestIdChange={
                       onSelectedPullRequestIdChange
                     }
@@ -399,6 +403,7 @@ export function WorkspaceTabs({
           error={pullRequestsError}
           isLoading={pullRequestsLoading}
           onOpenCommit={onSelectedCommitHashChange}
+          onOpenTerminal={onOpenMergeRecoveryTerminal}
           onSelectedPullRequestIdChange={onSelectedPullRequestIdChange}
           profiles={profiles}
           project={project}
diff --git a/desktop/src/features/projects/ui/PullRequestReviewCard.tsx b/desktop/src/features/projects/ui/PullRequestReviewCard.tsx
index 874304f2ab..bf2f074a8b 100644
--- a/desktop/src/features/projects/ui/PullRequestReviewCard.tsx
+++ b/desktop/src/features/projects/ui/PullRequestReviewCard.tsx
@@ -16,13 +16,18 @@ import {
 import { useIdentityQuery } from "@/shared/api/hooks";
 import { normalizePubkey } from "@/shared/lib/pubkey";
 import { Button } from "@/shared/ui/button";
-import { MergePullRequestButton } from "./MergePullRequestButton";
+import {
+  MergePullRequestButton,
+  type OpenMergeRecoveryTerminal,
+} from "./MergePullRequestButton";
 
 /** GitHub-style review state and actions rendered in the conversation flow. */
 export function PullRequestReviewCard({
+  onOpenTerminal,
   project,
   pullRequest,
 }: {
+  onOpenTerminal?: OpenMergeRecoveryTerminal;
   project: Project;
   pullRequest: ProjectPullRequest;
 }) {
@@ -236,6 +241,7 @@ export function PullRequestReviewCard({
             ) : null}
             {canMerge ? (
               <MergePullRequestButton
+                onOpenTerminal={onOpenTerminal}
                 project={project}
                 pullRequest={pullRequest}
               />
diff --git a/desktop/src/shared/api/projectGit.ts b/desktop/src/shared/api/projectGit.ts
index a212623617..a69a58260b 100644
--- a/desktop/src/shared/api/projectGit.ts
+++ b/desktop/src/shared/api/projectGit.ts
@@ -9,7 +9,7 @@ import type {
   ProjectRepoSnapshot,
   ProjectRepoSyncStatus,
 } from "@/shared/api/types";
-import { invokeTauri } from "@/shared/api/tauri";
+import { invokeTauri, TauriInvokeError } from "@/shared/api/tauri";
 
 type RawProjectRepoCommit = {
   hash: string;
@@ -337,6 +337,34 @@ export async function openProjectTerminal(input: {
   };
 }
 
+export async function openProjectMergeRecoveryTerminal(input: {
+  reposDir?: string | null;
+  projectDtag: string;
+  targetCloneUrl: string;
+  sourceCloneUrl: string;
+  targetBranch: string;
+  sourceBranch: string;
+  expectedCommit: string;
+}): Promise<{
+  path: string;
+  cloned: boolean;
+  recoveryRef: string;
+  targetRef: string;
+}> {
+  const result = await invokeTauri<{
+    path: string;
+    cloned: boolean;
+    recoveryRef: string;
+    targetRef: string;
+  }>("open_project_merge_recovery_terminal", {
+    input: {
+      ...input,
+      reposDir: input.reposDir ?? null,
+    },
+  });
+  return result;
+}
+
 export async function pushProjectLocalRepository(input: {
   reposDir?: string | null;
   projectDtag: string;
@@ -405,6 +433,84 @@ type RawProjectRepoMergeResult = {
   status_publication_error: string | null;
 };
 
+export type ProjectPullRequestMergeRecovery = {
+  action: "open_terminal";
+  targetBranch: string;
+  sourceBranch: string;
+};
+
+/** Machine-readable pull-request merge failure returned by the desktop shell. */
+export class ProjectPullRequestMergeError extends Error {
+  readonly code: string;
+  readonly recovery: ProjectPullRequestMergeRecovery | null;
+
+  constructor(
+    code: string,
+    message: string,
+    recovery: ProjectPullRequestMergeRecovery | null,
+  ) {
+    super(message);
+    this.name = "ProjectPullRequestMergeError";
+    this.code = code;
+    this.recovery = recovery;
+  }
+}
+
+function mergeErrorPayload(error: unknown): unknown {
+  const payload = error instanceof TauriInvokeError ? error.payload : error;
+  if (typeof payload !== "string") return payload;
+  try {
+    return JSON.parse(payload);
+  } catch {
+    return null;
+  }
+}
+
+/** Parse a structured native merge error without classifying generic failures. */
+export function parseProjectPullRequestMergeError(
+  error: unknown,
+): ProjectPullRequestMergeError | null {
+  const payload = mergeErrorPayload(error);
+  if (!payload || typeof payload !== "object") return null;
+  const candidate = payload as {
+    code?: unknown;
+    message?: unknown;
+    recovery?: unknown;
+  };
+  if (
+    typeof candidate.code !== "string" ||
+    typeof candidate.message !== "string"
+  ) {
+    return null;
+  }
+  let recovery: ProjectPullRequestMergeRecovery | null = null;
+  if (candidate.recovery !== null && candidate.recovery !== undefined) {
+    if (typeof candidate.recovery !== "object") return null;
+    const value = candidate.recovery as {
+      action?: unknown;
+      sourceBranch?: unknown;
+      targetBranch?: unknown;
+    };
+    if (
+      value.action !== "open_terminal" ||
+      typeof value.targetBranch !== "string" ||
+      typeof value.sourceBranch !== "string"
+    ) {
+      return null;
+    }
+    recovery = {
+      action: value.action,
+      sourceBranch: value.sourceBranch,
+      targetBranch: value.targetBranch,
+    };
+  }
+  return new ProjectPullRequestMergeError(
+    candidate.code,
+    candidate.message,
+    recovery,
+  );
+}
+
 export async function mergeProjectPullRequest(input: {
   targetCloneUrl: string;
   sourceCloneUrl: string;
@@ -417,12 +523,17 @@ export async function mergeProjectPullRequest(input: {
   sourceBranch: string;
   expectedCommit: string;
 }): Promise<ProjectRepoMergeResult> {
-  const result = await invokeTauri<RawProjectRepoMergeResult>(
-    "merge_project_pull_request",
-    {
-      input,
-    },
-  );
+  let result: RawProjectRepoMergeResult;
+  try {
+    result = await invokeTauri<RawProjectRepoMergeResult>(
+      "merge_project_pull_request",
+      {
+        input,
+      },
+    );
+  } catch (error) {
+    throw parseProjectPullRequestMergeError(error) ?? error;
+  }
   return {
     message: result.message,
     mergeCommit: result.merge_commit,
diff --git a/desktop/src/shared/api/projectGitMergeError.test.mjs b/desktop/src/shared/api/projectGitMergeError.test.mjs
new file mode 100644
index 0000000000..6acac00ec0
--- /dev/null
+++ b/desktop/src/shared/api/projectGitMergeError.test.mjs
@@ -0,0 +1,43 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+  parseProjectPullRequestMergeError,
+  ProjectPullRequestMergeError,
+} from "./projectGit.ts";
+
+const CONFLICT = {
+  code: "merge_conflict",
+  message: "Pull request has merge conflicts.",
+  recovery: {
+    action: "open_terminal",
+    targetBranch: "main",
+    sourceBranch: "feature/demo",
+  },
+};
+
+test("parses structured merge conflict recovery metadata", () => {
+  const error = parseProjectPullRequestMergeError(CONFLICT);
+
+  assert.ok(error instanceof ProjectPullRequestMergeError);
+  assert.equal(error.code, "merge_conflict");
+  assert.deepEqual(error.recovery, CONFLICT.recovery);
+});
+
+test("parses JSON-serialized Tauri merge errors", () => {
+  const error = parseProjectPullRequestMergeError(JSON.stringify(CONFLICT));
+
+  assert.ok(error instanceof ProjectPullRequestMergeError);
+  assert.equal(error.message, "Pull request has merge conflicts.");
+});
+
+test("rejects malformed recovery metadata", () => {
+  assert.equal(
+    parseProjectPullRequestMergeError({
+      ...CONFLICT,
+      recovery: { ...CONFLICT.recovery, targetBranch: null },
+    }),
+    null,
+  );
+  assert.equal(parseProjectPullRequestMergeError(new Error("offline")), null);
+});
diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts
index 0898016132..b5036e503c 100644
--- a/desktop/src/shared/api/tauri.ts
+++ b/desktop/src/shared/api/tauri.ts
@@ -241,13 +241,24 @@ type RawSetCanvasResult = {
   event_id: string;
 };
 
+/** Error normalized from a rejected Tauri invocation with its wire payload. */
+export class TauriInvokeError extends Error {
+  readonly payload: unknown;
+
+  constructor(message: string, payload: unknown) {
+    super(message);
+    this.name = "TauriInvokeError";
+    this.payload = payload;
+  }
+}
+
 function toTauriError(error: unknown): Error {
   if (error instanceof Error) {
     return error;
   }
 
   if (typeof error === "string") {
-    return new Error(error);
+    return new TauriInvokeError(error, error);
   }
 
   if (
@@ -256,13 +267,13 @@ function toTauriError(error: unknown): Error {
     "message" in error &&
     typeof error.message === "string"
   ) {
-    return new Error(error.message);
+    return new TauriInvokeError(error.message, error);
   }
 
   try {
-    return new Error(JSON.stringify(error));
+    return new TauriInvokeError(JSON.stringify(error), error);
   } catch {
-    return new Error("Unknown Tauri error");
+    return new TauriInvokeError("Unknown Tauri error", error);
   }
 }
 
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 45c0041529..79ef67173d 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -940,6 +940,16 @@ declare global {
     }>;
     /** Project event kinds rejected once, in order, to exercise retry flows. */
     __BUZZ_E2E_REJECT_PROJECT_EVENT_KINDS__?: number[];
+    /** Structured merge error returned by the mock native merge command. */
+    __BUZZ_E2E_PROJECT_MERGE_ERROR__?: {
+      code: string;
+      message: string;
+      recovery: {
+        action: "open_terminal";
+        sourceBranch: string;
+        targetBranch: string;
+      } | null;
+    };
     /** Overrides the first mock repository owner for delegated-owner tests. */
     __BUZZ_E2E_PROJECT_OWNER_OVERRIDE__?: string;
     /** Project history kinds rejected with CLOSED for aggregate-query tests. */
@@ -9390,6 +9400,9 @@ export function maybeInstallE2eTauriMocks() {
             "Only the repository owner or the owner of its managed agent can merge pull requests.",
           );
         }
+        if (window.__BUZZ_E2E_PROJECT_MERGE_ERROR__) {
+          throw window.__BUZZ_E2E_PROJECT_MERGE_ERROR__;
+        }
         const mergeCommit = "abcdef0123456789abcdef0123456789abcdef01";
         const statusEvent = createMockEvent(
           KIND_GIT_STATUS_MERGED,
@@ -9431,6 +9444,22 @@ export function maybeInstallE2eTauriMocks() {
           status_publication_error: statusPublicationError,
         };
       }
+      case "open_project_merge_recovery_terminal": {
+        const { input } = payload as {
+          input: { expectedCommit: string };
+        };
+        return {
+          path: "/tmp/buzz/REPOS/buzz",
+          cloned: false,
+          recoveryRef: `refs/buzz/merge-recovery/${input.expectedCommit}`,
+          targetRef: `refs/buzz/merge-recovery-target/${"f".repeat(40)}`,
+        };
+      }
+      case "open_project_terminal":
+        return {
+          path: "/tmp/buzz/REPOS/buzz",
+          cloned: false,
+        };
       case "get_relay_ws_url":
         return getRelayWsUrl(activeConfig);
       case "get_default_relay_url":
diff --git a/desktop/tests/e2e/project-pr-review.spec.ts b/desktop/tests/e2e/project-pr-review.spec.ts
index 78ded53927..c16324a059 100644
--- a/desktop/tests/e2e/project-pr-review.spec.ts
+++ b/desktop/tests/e2e/project-pr-review.spec.ts
@@ -4,6 +4,7 @@ import { waitForAnimations } from "../helpers/animations";
 import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
 
 const SHOTS = "test-results/project-pr-review";
+const RECOVERY_SHOTS = "test-results/project-pr-conflict-recovery";
 const REVIEWER_AGENT_PUBKEY = "a".repeat(64);
 const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
 
@@ -267,6 +268,78 @@ test("PR creator/owner can toggle draft, request reviews, and approve", async ({
   });
 });
 
+test("merge conflicts offer persistent terminal recovery", async ({ page }) => {
+  await enableProjectsFeature(page);
+  await installMockBridge(page);
+  await openBuzzProject(page);
+  await page.evaluate(() => {
+    window.__BUZZ_E2E_PROJECT_MERGE_ERROR__ = {
+      code: "merge_conflict",
+      message: "Pull request has merge conflicts.",
+      recovery: {
+        action: "open_terminal",
+        sourceBranch: "feature",
+        targetBranch: "main",
+      },
+    };
+  });
+
+  await page.getByRole("tab", { name: "Pull Request" }).click();
+  const aliceRow = page
+    .getByTestId("project-pull-request-row")
+    .filter({ hasText: "alice" })
+    .first();
+  await aliceRow.getByRole("button", { name: /^#/ }).click();
+  await page.getByRole("button", { name: "Merge", exact: true }).click();
+  await page.getByTestId("merge-pull-request-confirm-button").click();
+
+  const recovery = page.getByTestId("merge-conflict-recovery");
+  await expect(recovery).toBeVisible();
+  await expect(
+    recovery.getByRole("button", { name: "Copy commands" }),
+  ).toBeDisabled();
+  await waitForAnimations(page);
+  await recovery.screenshot({
+    path: `${RECOVERY_SHOTS}/01-merge-conflict.png`,
+  });
+  await recovery.getByRole("button", { name: "Resolve in Terminal" }).click();
+  await expect(
+    page.getByText("Recovery commit fetched and terminal opened."),
+  ).toBeVisible();
+  await expect(
+    page.getByText("Recovery commit fetched and terminal opened."),
+  ).toBeHidden({ timeout: 10_000 });
+  await expect(recovery).toContainText("git switch 'main'");
+  await expect(recovery).toContainText("git merge 'refs/buzz/merge-recovery/");
+  await expect(
+    recovery.getByRole("button", { name: "Copy commands" }),
+  ).toBeEnabled();
+  await waitForAnimations(page);
+  await recovery.screenshot({
+    path: `${RECOVERY_SHOTS}/02-merge-conflict-prepared.png`,
+  });
+
+  await expect
+    .poll(() =>
+      page.evaluate(
+        () =>
+          window.__BUZZ_E2E_COMMAND_PAYLOADS__?.find(
+            (entry) => entry.command === "open_project_merge_recovery_terminal",
+          ) ?? null,
+      ),
+    )
+    .toMatchObject({
+      command: "open_project_merge_recovery_terminal",
+      payload: {
+        input: {
+          expectedCommit: expect.any(String),
+          sourceBranch: "feature",
+          targetBranch: "main",
+        },
+      },
+    });
+});
+
 test("reviewer can leave a commit-scoped inline diff comment", async ({
   page,
 }) => {
