git: Add the ability to resolve merge conflicts with the agent (#49807)

Danilo Leal , Bennet Bo Fenner , and Zed Zippy created

This PR adds a "Resolve with Agent" button in each merge conflict block,
as well as "Resolve Conflicts with Agents" button on a notification for
resolving conflicts across all the files that have any. When clicking on
either of these buttons, the agent panel opens up with a template prompt
auto-submitted. For the first case, the specific content of the merge
block is already attached as context for the agent to act quickly, given
it's a local and small context. For the second case (all conflicts
across the codebase), the prompt just indicates to the agent which files
have conflicts and then it's up for the agent to see them. This felt
like a simpler way to go as opposed to extracting the content for all
merge conflicts across all damaged files.

Here's how the UI looks like:

<img width="550" height="1964" alt="Screenshot 2026-02-21 at 11  04@2x"
src="https://github.com/user-attachments/assets/96815545-ba03-40e5-9cb0-db0ce9588915"
/>

---

Before you mark this PR as ready for review, make sure that you have:
- [x] Added a solid test coverage and/or screenshots from doing manual
testing
- [x] Done a self-review taking into account security and performance
aspects
- [x] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

Release Notes:

- Git: Added the ability to quickly resolve merge conflicts with the
agent.

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>

Change summary

assets/icons/git_merge_conflict.svg                |   7 
crates/acp_thread/src/mention.rs                   |  19 
crates/agent/src/thread.rs                         |  21 
crates/agent_ui/src/agent_panel.rs                 | 442 +++++++++++++++
crates/agent_ui/src/connection_view/thread_view.rs |   1 
crates/agent_ui/src/mention_set.rs                 |   7 
crates/agent_ui/src/ui/mention_crease.rs           |   3 
crates/git_ui/src/conflict_view.rs                 | 154 +++++
crates/git_ui/src/git_ui.rs                        |   1 
crates/icons/src/icons.rs                          |   1 
crates/zed_actions/src/lib.rs                      |  27 
11 files changed, 675 insertions(+), 8 deletions(-)

Detailed changes

assets/icons/git_merge_conflict.svg 🔗

@@ -0,0 +1,7 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8 4.32848H10.4477C10.7723 4.32848 11.0835 4.45742 11.3131 4.68693C11.5426 4.91644 11.6715 5.22773 11.6715 5.55232V9.83575" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4.32849 8V13.5073" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.16426 2.49272L2.49274 6.16424" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.16426 6.16424L2.49274 2.49272" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11.6715 13.5073C12.6854 13.5073 13.5073 12.6854 13.5073 11.6715C13.5073 10.6577 12.6854 9.83575 11.6715 9.83575C10.6577 9.83575 9.83575 10.6577 9.83575 11.6715C9.83575 12.6854 10.6577 13.5073 11.6715 13.5073Z" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

crates/acp_thread/src/mention.rs 🔗

@@ -60,6 +60,9 @@ pub enum MentionUri {
     GitDiff {
         base_ref: String,
     },
+    MergeConflict {
+        file_path: String,
+    },
 }
 
 impl MentionUri {
@@ -215,6 +218,9 @@ impl MentionUri {
                     let base_ref =
                         single_query_param(&url, "base")?.unwrap_or_else(|| "main".to_string());
                     Ok(Self::GitDiff { base_ref })
+                } else if path.starts_with("/agent/merge-conflict") {
+                    let file_path = single_query_param(&url, "path")?.unwrap_or_default();
+                    Ok(Self::MergeConflict { file_path })
                 } else {
                     bail!("invalid zed url: {:?}", input);
                 }
@@ -245,6 +251,13 @@ impl MentionUri {
                 }
             }
             MentionUri::GitDiff { base_ref } => format!("Branch Diff ({})", base_ref),
+            MentionUri::MergeConflict { file_path } => {
+                let name = Path::new(file_path)
+                    .file_name()
+                    .unwrap_or_default()
+                    .to_string_lossy();
+                format!("Merge Conflict ({name})")
+            }
             MentionUri::Selection {
                 abs_path: path,
                 line_range,
@@ -306,6 +319,7 @@ impl MentionUri {
             MentionUri::Selection { .. } => IconName::Reader.path().into(),
             MentionUri::Fetch { .. } => IconName::ToolWeb.path().into(),
             MentionUri::GitDiff { .. } => IconName::GitBranch.path().into(),
+            MentionUri::MergeConflict { .. } => IconName::GitMergeConflict.path().into(),
         }
     }
 
@@ -409,6 +423,11 @@ impl MentionUri {
                 url.query_pairs_mut().append_pair("base", base_ref);
                 url
             }
+            MentionUri::MergeConflict { file_path } => {
+                let mut url = Url::parse("zed:///agent/merge-conflict").unwrap();
+                url.query_pairs_mut().append_pair("path", file_path);
+                url
+            }
         }
     }
 }

crates/agent/src/thread.rs 🔗

@@ -219,6 +219,7 @@ impl UserMessage {
             "<rules>\nThe user has specified the following rules that should be applied:\n";
         const OPEN_DIAGNOSTICS_TAG: &str = "<diagnostics>";
         const OPEN_DIFFS_TAG: &str = "<diffs>";
+        const MERGE_CONFLICT_TAG: &str = "<merge_conflicts>";
 
         let mut file_context = OPEN_FILES_TAG.to_string();
         let mut directory_context = OPEN_DIRECTORIES_TAG.to_string();
@@ -229,6 +230,7 @@ impl UserMessage {
         let mut rules_context = OPEN_RULES_TAG.to_string();
         let mut diagnostics_context = OPEN_DIAGNOSTICS_TAG.to_string();
         let mut diffs_context = OPEN_DIFFS_TAG.to_string();
+        let mut merge_conflict_context = MERGE_CONFLICT_TAG.to_string();
 
         for chunk in &self.content {
             let chunk = match chunk {
@@ -336,6 +338,18 @@ impl UserMessage {
                             )
                             .ok();
                         }
+                        MentionUri::MergeConflict { file_path } => {
+                            write!(
+                                &mut merge_conflict_context,
+                                "\nMerge conflict in {}:\n{}",
+                                file_path,
+                                MarkdownCodeBlock {
+                                    tag: "diff",
+                                    text: content
+                                }
+                            )
+                            .ok();
+                        }
                     }
 
                     language_model::MessageContent::Text(uri.as_link().to_string())
@@ -410,6 +424,13 @@ impl UserMessage {
                 .push(language_model::MessageContent::Text(diagnostics_context));
         }
 
+        if merge_conflict_context.len() > MERGE_CONFLICT_TAG.len() {
+            merge_conflict_context.push_str("</merge_conflicts>\n");
+            message
+                .content
+                .push(language_model::MessageContent::Text(merge_conflict_context));
+        }
+
         if message.content.len() > len_before_context {
             message.content.insert(
                 len_before_context,

crates/agent_ui/src/agent_panel.rs 🔗

@@ -13,6 +13,7 @@ use acp_thread::{AcpThread, MentionUri, ThreadStatus};
 use agent::{ContextServerRegistry, SharedThread, ThreadStore};
 use agent_client_protocol as acp;
 use agent_servers::AgentServer;
+use collections::HashSet;
 use db::kvp::{Dismissable, KEY_VALUE_STORE};
 use itertools::Itertools;
 use project::{
@@ -23,7 +24,10 @@ use serde::{Deserialize, Serialize};
 use settings::{LanguageModelProviderSetting, LanguageModelSelection};
 
 use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _};
-use zed_actions::agent::{OpenClaudeAgentOnboardingModal, ReauthenticateAgent, ReviewBranchDiff};
+use zed_actions::agent::{
+    ConflictContent, OpenClaudeAgentOnboardingModal, ReauthenticateAgent,
+    ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, ReviewBranchDiff,
+};
 
 use crate::ManageProfiles;
 use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal};
@@ -358,6 +362,55 @@ pub fn init(cx: &mut App) {
                         );
                     });
                 })
+                .register_action(
+                    |workspace, action: &ResolveConflictsWithAgent, window, cx| {
+                        let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
+                            return;
+                        };
+
+                        let content_blocks = build_conflict_resolution_prompt(&action.conflicts);
+
+                        workspace.focus_panel::<AgentPanel>(window, cx);
+
+                        panel.update(cx, |panel, cx| {
+                            panel.external_thread(
+                                None,
+                                None,
+                                Some(AgentInitialContent::ContentBlock {
+                                    blocks: content_blocks,
+                                    auto_submit: true,
+                                }),
+                                window,
+                                cx,
+                            );
+                        });
+                    },
+                )
+                .register_action(
+                    |workspace, action: &ResolveConflictedFilesWithAgent, window, cx| {
+                        let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
+                            return;
+                        };
+
+                        let content_blocks =
+                            build_conflicted_files_resolution_prompt(&action.conflicted_file_paths);
+
+                        workspace.focus_panel::<AgentPanel>(window, cx);
+
+                        panel.update(cx, |panel, cx| {
+                            panel.external_thread(
+                                None,
+                                None,
+                                Some(AgentInitialContent::ContentBlock {
+                                    blocks: content_blocks,
+                                    auto_submit: true,
+                                }),
+                                window,
+                                cx,
+                            );
+                        });
+                    },
+                )
                 .register_action(|workspace, action: &StartThreadIn, _window, cx| {
                     if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
                         panel.update(cx, |panel, cx| {
@@ -370,6 +423,113 @@ pub fn init(cx: &mut App) {
     .detach();
 }
 
+fn conflict_resource_block(conflict: &ConflictContent) -> acp::ContentBlock {
+    let mention_uri = MentionUri::MergeConflict {
+        file_path: conflict.file_path.clone(),
+    };
+    acp::ContentBlock::Resource(acp::EmbeddedResource::new(
+        acp::EmbeddedResourceResource::TextResourceContents(acp::TextResourceContents::new(
+            conflict.conflict_text.clone(),
+            mention_uri.to_uri().to_string(),
+        )),
+    ))
+}
+
+fn build_conflict_resolution_prompt(conflicts: &[ConflictContent]) -> Vec<acp::ContentBlock> {
+    if conflicts.is_empty() {
+        return Vec::new();
+    }
+
+    let mut blocks = Vec::new();
+
+    if conflicts.len() == 1 {
+        let conflict = &conflicts[0];
+
+        blocks.push(acp::ContentBlock::Text(acp::TextContent::new(
+            "Please resolve the following merge conflict in ",
+        )));
+        let mention = MentionUri::File {
+            abs_path: PathBuf::from(conflict.file_path.clone()),
+        };
+        blocks.push(acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
+            mention.name(),
+            mention.to_uri(),
+        )));
+
+        blocks.push(acp::ContentBlock::Text(acp::TextContent::new(
+            indoc::formatdoc!(
+                "\nThe conflict is between branch `{ours}` (ours) and `{theirs}` (theirs).
+
+                Analyze both versions carefully and resolve the conflict by editing \
+                the file directly. Choose the resolution that best preserves the intent \
+                of both changes, or combine them if appropriate.
+
+                ",
+                ours = conflict.ours_branch_name,
+                theirs = conflict.theirs_branch_name,
+            ),
+        )));
+    } else {
+        let n = conflicts.len();
+        let unique_files: HashSet<&str> = conflicts.iter().map(|c| c.file_path.as_str()).collect();
+        let ours = &conflicts[0].ours_branch_name;
+        let theirs = &conflicts[0].theirs_branch_name;
+        blocks.push(acp::ContentBlock::Text(acp::TextContent::new(
+            indoc::formatdoc!(
+                "Please resolve all {n} merge conflicts below.
+
+                The conflicts are between branch `{ours}` (ours) and `{theirs}` (theirs).
+
+                For each conflict, analyze both versions carefully and resolve them \
+                by editing the file{suffix} directly. Choose resolutions that best preserve \
+                the intent of both changes, or combine them if appropriate.
+
+                ",
+                suffix = if unique_files.len() > 1 { "s" } else { "" },
+            ),
+        )));
+    }
+
+    for conflict in conflicts {
+        blocks.push(conflict_resource_block(conflict));
+    }
+
+    blocks
+}
+
+fn build_conflicted_files_resolution_prompt(
+    conflicted_file_paths: &[String],
+) -> Vec<acp::ContentBlock> {
+    if conflicted_file_paths.is_empty() {
+        return Vec::new();
+    }
+
+    let instruction = indoc::indoc!(
+        "The following files have unresolved merge conflicts. Please open each \
+         file, find the conflict markers (`<<<<<<<` / `=======` / `>>>>>>>`), \
+         and resolve every conflict by editing the files directly.
+
+         Choose resolutions that best preserve the intent of both changes, \
+         or combine them if appropriate.
+
+         Files with conflicts:
+         ",
+    );
+
+    let mut content = vec![acp::ContentBlock::Text(acp::TextContent::new(instruction))];
+    for path in conflicted_file_paths {
+        let mention = MentionUri::File {
+            abs_path: PathBuf::from(path),
+        };
+        content.push(acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
+            mention.name(),
+            mention.to_uri(),
+        )));
+        content.push(acp::ContentBlock::Text(acp::TextContent::new("\n")));
+    }
+    content
+}
+
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 enum HistoryKind {
     AgentThreads,
@@ -4920,6 +5080,286 @@ mod tests {
         cx.run_until_parked();
     }
 
+    /// Extracts the text from a Text content block, panicking if it's not Text.
+    fn expect_text_block(block: &acp::ContentBlock) -> &str {
+        match block {
+            acp::ContentBlock::Text(t) => t.text.as_str(),
+            other => panic!("expected Text block, got {:?}", other),
+        }
+    }
+
+    /// Extracts the (text_content, uri) from a Resource content block, panicking
+    /// if it's not a TextResourceContents resource.
+    fn expect_resource_block(block: &acp::ContentBlock) -> (&str, &str) {
+        match block {
+            acp::ContentBlock::Resource(r) => match &r.resource {
+                acp::EmbeddedResourceResource::TextResourceContents(t) => {
+                    (t.text.as_str(), t.uri.as_str())
+                }
+                other => panic!("expected TextResourceContents, got {:?}", other),
+            },
+            other => panic!("expected Resource block, got {:?}", other),
+        }
+    }
+
+    #[test]
+    fn test_build_conflict_resolution_prompt_single_conflict() {
+        let conflicts = vec![ConflictContent {
+            file_path: "src/main.rs".to_string(),
+            conflict_text: "<<<<<<< HEAD\nlet x = 1;\n=======\nlet x = 2;\n>>>>>>> feature"
+                .to_string(),
+            ours_branch_name: "HEAD".to_string(),
+            theirs_branch_name: "feature".to_string(),
+        }];
+
+        let blocks = build_conflict_resolution_prompt(&conflicts);
+        // 2 Text blocks + 1 ResourceLink + 1 Resource for the conflict
+        assert_eq!(
+            blocks.len(),
+            4,
+            "expected 2 text + 1 resource link + 1 resource block"
+        );
+
+        let intro_text = expect_text_block(&blocks[0]);
+        assert!(
+            intro_text.contains("Please resolve the following merge conflict in"),
+            "prompt should include single-conflict intro text"
+        );
+
+        match &blocks[1] {
+            acp::ContentBlock::ResourceLink(link) => {
+                assert!(
+                    link.uri.contains("file://"),
+                    "resource link URI should use file scheme"
+                );
+                assert!(
+                    link.uri.contains("main.rs"),
+                    "resource link URI should reference file path"
+                );
+            }
+            other => panic!("expected ResourceLink block, got {:?}", other),
+        }
+
+        let body_text = expect_text_block(&blocks[2]);
+        assert!(
+            body_text.contains("`HEAD` (ours)"),
+            "prompt should mention ours branch"
+        );
+        assert!(
+            body_text.contains("`feature` (theirs)"),
+            "prompt should mention theirs branch"
+        );
+        assert!(
+            body_text.contains("editing the file directly"),
+            "prompt should instruct the agent to edit the file"
+        );
+
+        let (resource_text, resource_uri) = expect_resource_block(&blocks[3]);
+        assert!(
+            resource_text.contains("<<<<<<< HEAD"),
+            "resource should contain the conflict text"
+        );
+        assert!(
+            resource_uri.contains("merge-conflict"),
+            "resource URI should use the merge-conflict scheme"
+        );
+        assert!(
+            resource_uri.contains("main.rs"),
+            "resource URI should reference the file path"
+        );
+    }
+
+    #[test]
+    fn test_build_conflict_resolution_prompt_multiple_conflicts_same_file() {
+        let conflicts = vec![
+            ConflictContent {
+                file_path: "src/lib.rs".to_string(),
+                conflict_text: "<<<<<<< main\nfn a() {}\n=======\nfn a_v2() {}\n>>>>>>> dev"
+                    .to_string(),
+                ours_branch_name: "main".to_string(),
+                theirs_branch_name: "dev".to_string(),
+            },
+            ConflictContent {
+                file_path: "src/lib.rs".to_string(),
+                conflict_text: "<<<<<<< main\nfn b() {}\n=======\nfn b_v2() {}\n>>>>>>> dev"
+                    .to_string(),
+                ours_branch_name: "main".to_string(),
+                theirs_branch_name: "dev".to_string(),
+            },
+        ];
+
+        let blocks = build_conflict_resolution_prompt(&conflicts);
+        // 1 Text instruction + 2 Resource blocks
+        assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks");
+
+        let text = expect_text_block(&blocks[0]);
+        assert!(
+            text.contains("all 2 merge conflicts"),
+            "prompt should mention the total count"
+        );
+        assert!(
+            text.contains("`main` (ours)"),
+            "prompt should mention ours branch"
+        );
+        assert!(
+            text.contains("`dev` (theirs)"),
+            "prompt should mention theirs branch"
+        );
+        // Single file, so "file" not "files"
+        assert!(
+            text.contains("file directly"),
+            "single file should use singular 'file'"
+        );
+
+        let (resource_a, _) = expect_resource_block(&blocks[1]);
+        let (resource_b, _) = expect_resource_block(&blocks[2]);
+        assert!(
+            resource_a.contains("fn a()"),
+            "first resource should contain first conflict"
+        );
+        assert!(
+            resource_b.contains("fn b()"),
+            "second resource should contain second conflict"
+        );
+    }
+
+    #[test]
+    fn test_build_conflict_resolution_prompt_multiple_conflicts_different_files() {
+        let conflicts = vec![
+            ConflictContent {
+                file_path: "src/a.rs".to_string(),
+                conflict_text: "<<<<<<< main\nA\n=======\nB\n>>>>>>> dev".to_string(),
+                ours_branch_name: "main".to_string(),
+                theirs_branch_name: "dev".to_string(),
+            },
+            ConflictContent {
+                file_path: "src/b.rs".to_string(),
+                conflict_text: "<<<<<<< main\nC\n=======\nD\n>>>>>>> dev".to_string(),
+                ours_branch_name: "main".to_string(),
+                theirs_branch_name: "dev".to_string(),
+            },
+        ];
+
+        let blocks = build_conflict_resolution_prompt(&conflicts);
+        // 1 Text instruction + 2 Resource blocks
+        assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks");
+
+        let text = expect_text_block(&blocks[0]);
+        assert!(
+            text.contains("files directly"),
+            "multiple files should use plural 'files'"
+        );
+
+        let (_, uri_a) = expect_resource_block(&blocks[1]);
+        let (_, uri_b) = expect_resource_block(&blocks[2]);
+        assert!(
+            uri_a.contains("a.rs"),
+            "first resource URI should reference a.rs"
+        );
+        assert!(
+            uri_b.contains("b.rs"),
+            "second resource URI should reference b.rs"
+        );
+    }
+
+    #[test]
+    fn test_build_conflicted_files_resolution_prompt_file_paths_only() {
+        let file_paths = vec![
+            "src/main.rs".to_string(),
+            "src/lib.rs".to_string(),
+            "tests/integration.rs".to_string(),
+        ];
+
+        let blocks = build_conflicted_files_resolution_prompt(&file_paths);
+        // 1 instruction Text block + (ResourceLink + newline Text) per file
+        assert_eq!(
+            blocks.len(),
+            1 + (file_paths.len() * 2),
+            "expected instruction text plus resource links and separators"
+        );
+
+        let text = expect_text_block(&blocks[0]);
+        assert!(
+            text.contains("unresolved merge conflicts"),
+            "prompt should describe the task"
+        );
+        assert!(
+            text.contains("conflict markers"),
+            "prompt should mention conflict markers"
+        );
+
+        for (index, path) in file_paths.iter().enumerate() {
+            let link_index = 1 + (index * 2);
+            let newline_index = link_index + 1;
+
+            match &blocks[link_index] {
+                acp::ContentBlock::ResourceLink(link) => {
+                    assert!(
+                        link.uri.contains("file://"),
+                        "resource link URI should use file scheme"
+                    );
+                    assert!(
+                        link.uri.contains(path),
+                        "resource link URI should reference file path: {path}"
+                    );
+                }
+                other => panic!(
+                    "expected ResourceLink block at index {}, got {:?}",
+                    link_index, other
+                ),
+            }
+
+            let separator = expect_text_block(&blocks[newline_index]);
+            assert_eq!(
+                separator, "\n",
+                "expected newline separator after each file"
+            );
+        }
+    }
+
+    #[test]
+    fn test_build_conflict_resolution_prompt_empty_conflicts() {
+        let blocks = build_conflict_resolution_prompt(&[]);
+        assert!(
+            blocks.is_empty(),
+            "empty conflicts should produce no blocks, got {} blocks",
+            blocks.len()
+        );
+    }
+
+    #[test]
+    fn test_build_conflicted_files_resolution_prompt_empty_paths() {
+        let blocks = build_conflicted_files_resolution_prompt(&[]);
+        assert!(
+            blocks.is_empty(),
+            "empty paths should produce no blocks, got {} blocks",
+            blocks.len()
+        );
+    }
+
+    #[test]
+    fn test_conflict_resource_block_structure() {
+        let conflict = ConflictContent {
+            file_path: "src/utils.rs".to_string(),
+            conflict_text: "<<<<<<< HEAD\nold code\n=======\nnew code\n>>>>>>> branch".to_string(),
+            ours_branch_name: "HEAD".to_string(),
+            theirs_branch_name: "branch".to_string(),
+        };
+
+        let block = conflict_resource_block(&conflict);
+        let (text, uri) = expect_resource_block(&block);
+
+        assert_eq!(
+            text, conflict.conflict_text,
+            "resource text should be the raw conflict"
+        );
+        assert!(
+            uri.starts_with("zed:///agent/merge-conflict"),
+            "URI should use the zed merge-conflict scheme, got: {uri}"
+        );
+        assert!(uri.contains("utils.rs"), "URI should encode the file path");
+    }
+
     async fn setup_panel(cx: &mut TestAppContext) -> (Entity<AgentPanel>, VisualTestContext) {
         init_test(cx);
         cx.update(|cx| {

crates/agent_ui/src/connection_view/thread_view.rs 🔗

@@ -7996,6 +7996,7 @@ pub(crate) fn open_link(
             MentionUri::Diagnostics { .. } => {}
             MentionUri::TerminalSelection { .. } => {}
             MentionUri::GitDiff { .. } => {}
+            MentionUri::MergeConflict { .. } => {}
         })
     } else {
         cx.open_url(&url);

crates/agent_ui/src/mention_set.rs 🔗

@@ -150,7 +150,8 @@ impl MentionSet {
             MentionUri::PastedImage
             | MentionUri::Selection { .. }
             | MentionUri::TerminalSelection { .. }
-            | MentionUri::GitDiff { .. } => {
+            | MentionUri::GitDiff { .. }
+            | MentionUri::MergeConflict { .. } => {
                 Task::ready(Err(anyhow!("Unsupported mention URI type for paste")))
             }
         }
@@ -301,6 +302,10 @@ impl MentionSet {
                 debug_panic!("unexpected git diff URI");
                 Task::ready(Err(anyhow!("unexpected git diff URI")))
             }
+            MentionUri::MergeConflict { .. } => {
+                debug_panic!("unexpected merge conflict URI");
+                Task::ready(Err(anyhow!("unexpected merge conflict URI")))
+            }
         };
         let task = cx
             .spawn(async move |_, _| task.await.map_err(|e| e.to_string()))

crates/agent_ui/src/ui/mention_crease.rs 🔗

@@ -187,7 +187,8 @@ fn open_mention_uri(
         | MentionUri::Selection { abs_path: None, .. }
         | MentionUri::Diagnostics { .. }
         | MentionUri::TerminalSelection { .. }
-        | MentionUri::GitDiff { .. } => {}
+        | MentionUri::GitDiff { .. }
+        | MentionUri::MergeConflict { .. } => {}
     });
 }
 

crates/git_ui/src/conflict_view.rs 🔗

@@ -1,3 +1,4 @@
+use agent_settings::AgentSettings;
 use collections::{HashMap, HashSet};
 use editor::{
     ConflictsOurs, ConflictsOursMarker, ConflictsOuter, ConflictsTheirs, ConflictsTheirsMarker,
@@ -5,14 +6,25 @@ use editor::{
     display_map::{BlockContext, BlockPlacement, BlockProperties, BlockStyle, CustomBlockId},
 };
 use gpui::{
-    App, Context, Entity, InteractiveElement as _, ParentElement as _, Subscription, Task,
-    WeakEntity,
+    App, Context, DismissEvent, Entity, InteractiveElement as _, ParentElement as _, Subscription,
+    Task, WeakEntity,
 };
 use language::{Anchor, Buffer, BufferId};
-use project::{ConflictRegion, ConflictSet, ConflictSetUpdate, ProjectItem as _};
+use project::{
+    ConflictRegion, ConflictSet, ConflictSetUpdate, ProjectItem as _,
+    git_store::{GitStoreEvent, RepositoryEvent},
+};
+use settings::Settings;
 use std::{ops::Range, sync::Arc};
-use ui::{ActiveTheme, Element as _, Styled, Window, prelude::*};
+use ui::{ActiveTheme, Divider, Element as _, Styled, Window, prelude::*};
 use util::{ResultExt as _, debug_panic, maybe};
+use workspace::{
+    Workspace,
+    notifications::{NotificationId, simple_message_notification::MessageNotification},
+};
+use zed_actions::agent::{
+    ConflictContent, ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent,
+};
 
 pub(crate) struct ConflictAddon {
     buffers: HashMap<BufferId, BufferConflicts>,
@@ -368,11 +380,12 @@ fn render_conflict_buttons(
     editor: WeakEntity<Editor>,
     cx: &mut BlockContext,
 ) -> AnyElement {
+    let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx);
+
     h_flex()
         .id(cx.block_id)
         .h(cx.line_height)
         .ml(cx.margins.gutter.width)
-        .items_end()
         .gap_1()
         .bg(cx.theme().colors().editor_background)
         .child(
@@ -419,6 +432,7 @@ fn render_conflict_buttons(
             Button::new("both", "Use Both")
                 .label_size(LabelSize::Small)
                 .on_click({
+                    let editor = editor.clone();
                     let conflict = conflict.clone();
                     let ours = conflict.ours.clone();
                     let theirs = conflict.theirs.clone();
@@ -435,9 +449,139 @@ fn render_conflict_buttons(
                     }
                 }),
         )
+        .when(is_ai_enabled, |this| {
+            this.child(Divider::vertical()).child(
+                Button::new("resolve-with-agent", "Resolve with Agent")
+                    .label_size(LabelSize::Small)
+                    .icon(IconName::ZedAssistant)
+                    .icon_position(IconPosition::Start)
+                    .icon_size(IconSize::Small)
+                    .icon_color(Color::Muted)
+                    .on_click({
+                        let conflict = conflict.clone();
+                        move |_, window, cx| {
+                            let content = editor
+                                .update(cx, |editor, cx| {
+                                    let multibuffer = editor.buffer().read(cx);
+                                    let buffer_id = conflict.ours.end.buffer_id?;
+                                    let buffer = multibuffer.buffer(buffer_id)?;
+                                    let buffer_read = buffer.read(cx);
+                                    let snapshot = buffer_read.snapshot();
+                                    let conflict_text = snapshot
+                                        .text_for_range(conflict.range.clone())
+                                        .collect::<String>();
+                                    let file_path = buffer_read
+                                        .file()
+                                        .and_then(|file| file.as_local())
+                                        .map(|f| f.abs_path(cx).to_string_lossy().to_string())
+                                        .unwrap_or_default();
+                                    Some(ConflictContent {
+                                        file_path,
+                                        conflict_text,
+                                        ours_branch_name: conflict.ours_branch_name.to_string(),
+                                        theirs_branch_name: conflict.theirs_branch_name.to_string(),
+                                    })
+                                })
+                                .ok()
+                                .flatten();
+                            if let Some(content) = content {
+                                window.dispatch_action(
+                                    Box::new(ResolveConflictsWithAgent {
+                                        conflicts: vec![content],
+                                    }),
+                                    cx,
+                                );
+                            }
+                        }
+                    }),
+            )
+        })
         .into_any()
 }
 
+struct MergeConflictNotification;
+
+fn merge_conflict_notification_id() -> NotificationId {
+    NotificationId::unique::<MergeConflictNotification>()
+}
+
+fn collect_conflicted_file_paths(workspace: &Workspace, cx: &App) -> Vec<String> {
+    let project = workspace.project().read(cx);
+    let git_store = project.git_store().read(cx);
+    let mut paths = Vec::new();
+
+    for repo in git_store.repositories().values() {
+        let snapshot = repo.read(cx).snapshot();
+        for (repo_path, _) in snapshot.merge.merge_heads_by_conflicted_path.iter() {
+            if let Some(project_path) = repo.read(cx).repo_path_to_project_path(repo_path, cx) {
+                paths.push(
+                    project_path
+                        .path
+                        .as_std_path()
+                        .to_string_lossy()
+                        .to_string(),
+                );
+            }
+        }
+    }
+
+    paths
+}
+
+pub(crate) fn register_conflict_notification(
+    workspace: &mut Workspace,
+    cx: &mut Context<Workspace>,
+) {
+    let git_store = workspace.project().read(cx).git_store().clone();
+
+    cx.subscribe(&git_store, |workspace, _git_store, event, cx| {
+        let conflicts_changed = matches!(
+            event,
+            GitStoreEvent::ConflictsUpdated
+                | GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::StatusesChanged, _)
+        );
+        if !AgentSettings::get_global(cx).enabled || !conflicts_changed {
+            return;
+        }
+
+        let paths = collect_conflicted_file_paths(workspace, cx);
+        let notification_id = merge_conflict_notification_id();
+
+        if paths.is_empty() {
+            workspace.dismiss_notification(&notification_id, cx);
+        } else {
+            let file_count = paths.len();
+            workspace.show_notification(notification_id, cx, |cx| {
+                cx.new(|cx| {
+                    let message = if file_count == 1 {
+                        "1 file has unresolved merge conflicts".to_string()
+                    } else {
+                        format!("{file_count} files have unresolved merge conflicts")
+                    };
+
+                    MessageNotification::new(message, cx)
+                        .primary_message("Resolve Conflicts with Agent")
+                        .primary_icon(IconName::ZedAssistant)
+                        .primary_icon_color(Color::Muted)
+                        .primary_on_click({
+                            let paths = paths.clone();
+                            move |window, cx| {
+                                window.dispatch_action(
+                                    Box::new(ResolveConflictedFilesWithAgent {
+                                        conflicted_file_paths: paths.clone(),
+                                    }),
+                                    cx,
+                                );
+                                cx.emit(DismissEvent);
+                            }
+                        })
+                })
+            });
+        }
+    })
+    .detach();
+}
+
 pub(crate) fn resolve_conflict(
     editor: WeakEntity<Editor>,
     excerpt_id: ExcerptId,

crates/git_ui/src/git_ui.rs 🔗

@@ -62,6 +62,7 @@ pub fn init(cx: &mut App) {
         git_panel::register(workspace);
         repository_selector::register(workspace);
         git_picker::register(workspace);
+        conflict_view::register_conflict_notification(workspace, cx);
 
         let project = workspace.project().read(cx);
         if project.is_read_only(cx) {

crates/icons/src/icons.rs 🔗

@@ -147,6 +147,7 @@ pub enum IconName {
     GitBranchPlus,
     GitCommit,
     GitGraph,
+    GitMergeConflict,
     Github,
     Hash,
     HistoryRerun,

crates/zed_actions/src/lib.rs 🔗

@@ -469,6 +469,33 @@ pub mod agent {
         /// The base ref that the diff was computed against (e.g. "main").
         pub base_ref: SharedString,
     }
+
+    /// A single merge conflict region extracted from a file.
+    #[derive(Clone, Debug, PartialEq, Deserialize, JsonSchema)]
+    pub struct ConflictContent {
+        pub file_path: String,
+        pub conflict_text: String,
+        pub ours_branch_name: String,
+        pub theirs_branch_name: String,
+    }
+
+    /// Opens a new agent thread to resolve specific merge conflicts.
+    #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
+    #[action(namespace = agent)]
+    #[serde(deny_unknown_fields)]
+    pub struct ResolveConflictsWithAgent {
+        /// Individual conflicts with their full text.
+        pub conflicts: Vec<ConflictContent>,
+    }
+
+    /// Opens a new agent thread to resolve merge conflicts in the given file paths.
+    #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
+    #[action(namespace = agent)]
+    #[serde(deny_unknown_fields)]
+    pub struct ResolveConflictedFilesWithAgent {
+        /// File paths with unresolved conflicts (for project-wide resolution).
+        pub conflicted_file_paths: Vec<String>,
+    }
 }
 
 pub mod assistant {