Display agent-powered merge conflict resolution in the status bar (#53033)

Danilo Leal created

Follow up to https://github.com/zed-industries/zed/pull/49807

Previously, when there were multiple conflicts across the codebase, we
would pop a toast at the bottom right corner of the UI. A toast seemed
like a functional idea because it'd be visible from any state of the app
and thus it'd be a good place to expose the button that allows you to
quickly prompt the agent to resolve all conflicts, as opposed to
creating a thread for each individual one. However, the toast was met
with some negative (and correct) feedback, mostly because it is
interruptive, and thus can sometimes block very relevant surfaces, like
either the agent panel itself or the Git commit area.

Therefore, in this PR, I'm removing the toast and adding a button in the
status bar instead; a bit more minimal, not interruptive, and a common
place for other items that might require your attention. The status bar
can be quite busy these days, though; we can display diagnostics, LSP
status, and file names in there; conscious of that. But it felt like it
could work given this button is such a transient one that you can either
easily manually dismiss or wait for it to be auto-dismissed as you or
the agent resolves the merge conflicts.

<img width="500" height="864" alt="Screenshot 2026-04-02 at 9  15@2x"
src="https://github.com/user-attachments/assets/4412a05c-77d0-4391-8ea1-25d1749b5e20"
/>

Release Notes:

- Git: Improved how we surface the affordance to resolve codebase-wide
merge conflicts with the agent in the UI.
- Agent: Added a setting to control whether or not the button to resolve
merge conflicts with the agent should be displayed.

Change summary

assets/settings/default.json                         |   5 
crates/agent/src/tool_permissions.rs                 |   1 
crates/agent_settings/src/agent_settings.rs          |   2 
crates/agent_ui/src/agent_ui.rs                      |   1 
crates/agent_ui/src/conversation_view/thread_view.rs |  24 -
crates/git_ui/src/conflict_view.rs                   | 248 +++++++++----
crates/git_ui/src/git_ui.rs                          |   3 
crates/settings_content/src/agent.rs                 |   5 
crates/settings_ui/src/page_data.rs                  |  18 +
crates/workspace/src/workspace.rs                    |   5 
crates/zed/src/zed.rs                                |   3 
11 files changed, 212 insertions(+), 103 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -1144,6 +1144,11 @@
     //
     // Default: false
     "show_turn_stats": false,
+    // Whether to show the merge conflict indicator in the status bar
+    // that offers to resolve conflicts using the agent.
+    //
+    // Default: true
+    "show_merge_conflict_indicator": true,
   },
   // Whether the screen sharing icon is shown in the os status bar.
   "show_call_status_icon": true,

crates/agent/src/tool_permissions.rs 🔗

@@ -595,6 +595,7 @@ mod tests {
             message_editor_min_lines: 1,
             tool_permissions,
             show_turn_stats: false,
+            show_merge_conflict_indicator: true,
             new_thread_location: Default::default(),
             sidebar_side: Default::default(),
             thinking_display: Default::default(),

crates/agent_settings/src/agent_settings.rs 🔗

@@ -176,6 +176,7 @@ pub struct AgentSettings {
     pub use_modifier_to_send: bool,
     pub message_editor_min_lines: usize,
     pub show_turn_stats: bool,
+    pub show_merge_conflict_indicator: bool,
     pub tool_permissions: ToolPermissions,
     pub new_thread_location: NewThreadLocation,
 }
@@ -629,6 +630,7 @@ impl Settings for AgentSettings {
             use_modifier_to_send: agent.use_modifier_to_send.unwrap(),
             message_editor_min_lines: agent.message_editor_min_lines.unwrap(),
             show_turn_stats: agent.show_turn_stats.unwrap(),
+            show_merge_conflict_indicator: agent.show_merge_conflict_indicator.unwrap(),
             tool_permissions: compile_tool_permissions(agent.tool_permissions),
             new_thread_location: agent.new_thread_location.unwrap_or_default(),
         }

crates/agent_ui/src/agent_ui.rs 🔗

@@ -734,6 +734,7 @@ mod tests {
             message_editor_min_lines: 1,
             tool_permissions: Default::default(),
             show_turn_stats: false,
+            show_merge_conflict_indicator: true,
             new_thread_location: Default::default(),
             sidebar_side: Default::default(),
             thinking_display: Default::default(),

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

@@ -816,13 +816,10 @@ impl ThreadView {
                 }
             }
         }));
-        if self.parent_id.is_none() {
-            self.suppress_merge_conflict_notification(cx);
-        }
         generation
     }
 
-    pub fn stop_turn(&mut self, generation: usize, cx: &mut Context<Self>) {
+    pub fn stop_turn(&mut self, generation: usize, _cx: &mut Context<Self>) {
         if self.turn_fields.turn_generation != generation {
             return;
         }
@@ -833,25 +830,6 @@ impl ThreadView {
             .map(|started| started.elapsed());
         self.turn_fields.last_turn_tokens = self.turn_fields.turn_tokens.take();
         self.turn_fields._turn_timer_task = None;
-        if self.parent_id.is_none() {
-            self.unsuppress_merge_conflict_notification(cx);
-        }
-    }
-
-    fn suppress_merge_conflict_notification(&self, cx: &mut Context<Self>) {
-        self.workspace
-            .update(cx, |workspace, cx| {
-                workspace.suppress_notification(&workspace::merge_conflict_notification_id(), cx);
-            })
-            .ok();
-    }
-
-    fn unsuppress_merge_conflict_notification(&self, cx: &mut Context<Self>) {
-        self.workspace
-            .update(cx, |workspace, _cx| {
-                workspace.unsuppress(workspace::merge_conflict_notification_id());
-            })
-            .ok();
     }
 
     pub fn update_turn_tokens(&mut self, cx: &App) {

crates/git_ui/src/conflict_view.rs 🔗

@@ -6,19 +6,19 @@ use editor::{
     display_map::{BlockContext, BlockPlacement, BlockProperties, BlockStyle, CustomBlockId},
 };
 use gpui::{
-    App, Context, DismissEvent, Entity, InteractiveElement as _, ParentElement as _, Subscription,
-    Task, WeakEntity,
+    App, ClickEvent, Context, Empty, Entity, InteractiveElement as _, ParentElement as _,
+    Subscription, Task, WeakEntity,
 };
 use language::{Anchor, Buffer, BufferId};
 use project::{
     ConflictRegion, ConflictSet, ConflictSetUpdate, Project, ProjectItem as _,
-    git_store::{GitStoreEvent, RepositoryEvent},
+    git_store::{GitStore, GitStoreEvent, RepositoryEvent},
 };
 use settings::Settings;
-use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc};
-use ui::{ActiveTheme, Divider, Element as _, Styled, Window, prelude::*};
+use std::{ops::Range, sync::Arc};
+use ui::{ButtonLike, Divider, Tooltip, prelude::*};
 use util::{ResultExt as _, debug_panic, maybe};
-use workspace::{Workspace, notifications::simple_message_notification::MessageNotification};
+use workspace::{StatusItemView, Workspace, item::ItemHandle};
 use zed_actions::agent::{
     ConflictContent, ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent,
 };
@@ -433,74 +433,6 @@ fn collect_conflicted_file_paths(project: &Project, cx: &App) -> Vec<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();
-
-    let last_shown_paths: Rc<RefCell<HashSet<String>>> = Rc::new(RefCell::new(HashSet::default()));
-
-    cx.subscribe(&git_store, move |workspace, _git_store, event, cx| {
-        let conflicts_changed = matches!(
-            event,
-            GitStoreEvent::ConflictsUpdated
-                | GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::StatusesChanged, _)
-        );
-        if !AgentSettings::get_global(cx).enabled(cx) || !conflicts_changed {
-            return;
-        }
-        let project = workspace.project().read(cx);
-        if project.is_via_collab() {
-            return;
-        }
-
-        if workspace.is_notification_suppressed(workspace::merge_conflict_notification_id()) {
-            return;
-        }
-
-        let paths = collect_conflicted_file_paths(project, cx);
-        let notification_id = workspace::merge_conflict_notification_id();
-        let current_paths_set: HashSet<String> = paths.iter().cloned().collect();
-
-        if paths.is_empty() {
-            last_shown_paths.borrow_mut().clear();
-            workspace.dismiss_notification(&notification_id, cx);
-        } else if *last_shown_paths.borrow() != current_paths_set {
-            // Only show the notification if the set of conflicted paths has changed.
-            // This prevents re-showing after the user dismisses it while working on the same conflicts.
-            *last_shown_paths.borrow_mut() = current_paths_set;
-            let file_count = paths.len();
-            workspace.show_notification(notification_id, cx, |cx| {
-                cx.new(|cx| {
-                    let message = format!(
-                        "{file_count} file{} have unresolved merge conflicts",
-                        if file_count == 1 { "" } else { "s" }
-                    );
-
-                    MessageNotification::new(message, cx)
-                        .primary_message("Resolve 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>,
     resolved_conflict: ConflictRegion,
@@ -573,3 +505,171 @@ pub(crate) fn resolve_conflict(
         }
     })
 }
+
+pub struct MergeConflictIndicator {
+    project: Entity<Project>,
+    conflicted_paths: Vec<String>,
+    last_shown_paths: HashSet<String>,
+    dismissed: bool,
+    _subscription: Subscription,
+}
+
+impl MergeConflictIndicator {
+    pub fn new(workspace: &Workspace, cx: &mut Context<Self>) -> Self {
+        let project = workspace.project().clone();
+        let git_store = project.read(cx).git_store().clone();
+
+        let subscription = cx.subscribe(&git_store, Self::on_git_store_event);
+
+        let conflicted_paths = collect_conflicted_file_paths(project.read(cx), cx);
+        let last_shown_paths: HashSet<String> = conflicted_paths.iter().cloned().collect();
+
+        Self {
+            project,
+            conflicted_paths,
+            last_shown_paths,
+            dismissed: false,
+            _subscription: subscription,
+        }
+    }
+
+    fn on_git_store_event(
+        &mut self,
+        _git_store: Entity<GitStore>,
+        event: &GitStoreEvent,
+        cx: &mut Context<Self>,
+    ) {
+        let conflicts_changed = matches!(
+            event,
+            GitStoreEvent::ConflictsUpdated
+                | GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::StatusesChanged, _)
+        );
+
+        let agent_settings = AgentSettings::get_global(cx);
+        if !agent_settings.enabled(cx)
+            || !agent_settings.show_merge_conflict_indicator
+            || !conflicts_changed
+        {
+            return;
+        }
+
+        let project = self.project.read(cx);
+        if project.is_via_collab() {
+            return;
+        }
+
+        let paths = collect_conflicted_file_paths(project, cx);
+        let current_paths_set: HashSet<String> = paths.iter().cloned().collect();
+
+        if paths.is_empty() {
+            self.conflicted_paths.clear();
+            self.last_shown_paths.clear();
+            self.dismissed = false;
+            cx.notify();
+        } else if self.last_shown_paths != current_paths_set {
+            self.last_shown_paths = current_paths_set;
+            self.conflicted_paths = paths;
+            self.dismissed = false;
+            cx.notify();
+        }
+    }
+
+    fn resolve_with_agent(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        window.dispatch_action(
+            Box::new(ResolveConflictedFilesWithAgent {
+                conflicted_file_paths: self.conflicted_paths.clone(),
+            }),
+            cx,
+        );
+        self.dismissed = true;
+        cx.notify();
+    }
+
+    fn dismiss(&mut self, _: &ClickEvent, _window: &mut Window, cx: &mut Context<Self>) {
+        self.dismissed = true;
+        cx.notify();
+    }
+}
+
+impl Render for MergeConflictIndicator {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let agent_settings = AgentSettings::get_global(cx);
+        if !agent_settings.enabled(cx)
+            || !agent_settings.show_merge_conflict_indicator
+            || self.conflicted_paths.is_empty()
+            || self.dismissed
+        {
+            return Empty.into_any_element();
+        }
+
+        let file_count = self.conflicted_paths.len();
+
+        let message: SharedString = format!(
+            "Resolve Merge Conflict{} with Agent",
+            if file_count == 1 { "" } else { "s" }
+        )
+        .into();
+
+        let tooltip_label: SharedString = format!(
+            "Found {} {} across the codebase",
+            file_count,
+            if file_count == 1 {
+                "conflict"
+            } else {
+                "conflicts"
+            }
+        )
+        .into();
+
+        let border_color = cx.theme().colors().text_accent.opacity(0.2);
+
+        h_flex()
+            .h(rems_from_px(22.))
+            .rounded_sm()
+            .border_1()
+            .border_color(border_color)
+            .child(
+                ButtonLike::new("update-button")
+                    .child(
+                        h_flex()
+                            .h_full()
+                            .gap_1()
+                            .child(
+                                Icon::new(IconName::GitMergeConflict)
+                                    .size(IconSize::Small)
+                                    .color(Color::Muted),
+                            )
+                            .child(Label::new(message).size(LabelSize::Small)),
+                    )
+                    .tooltip(move |_, cx| {
+                        Tooltip::with_meta(
+                            tooltip_label.clone(),
+                            None,
+                            "Click to Resolve with Agent",
+                            cx,
+                        )
+                    })
+                    .on_click(cx.listener(|this, _, window, cx| {
+                        this.resolve_with_agent(window, cx);
+                    })),
+            )
+            .child(
+                div().border_l_1().border_color(border_color).child(
+                    IconButton::new("dismiss-merge-conflicts", IconName::Close)
+                        .icon_size(IconSize::XSmall)
+                        .on_click(cx.listener(Self::dismiss)),
+                ),
+            )
+            .into_any_element()
+    }
+}
+
+impl StatusItemView for MergeConflictIndicator {
+    fn set_active_pane_item(
+        &mut self,
+        _: Option<&dyn ItemHandle>,
+        _window: &mut Window,
+        _: &mut Context<Self>,
+    ) {
+    }
+}

crates/git_ui/src/git_ui.rs 🔗

@@ -47,6 +47,8 @@ pub mod stash_picker;
 pub mod text_diff_view;
 pub mod worktree_picker;
 
+pub use conflict_view::MergeConflictIndicator;
+
 pub fn init(cx: &mut App) {
     editor::set_blame_renderer(blame_ui::GitBlameRenderer, cx);
     commit_view::init(cx);
@@ -62,7 +64,6 @@ 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/settings_content/src/agent.rs 🔗

@@ -209,6 +209,11 @@ pub struct AgentSettingsContent {
     ///
     /// Default: false
     pub show_turn_stats: Option<bool>,
+    /// Whether to show the merge conflict indicator in the status bar
+    /// that offers to resolve conflicts using the agent.
+    ///
+    /// Default: true
+    pub show_merge_conflict_indicator: Option<bool>,
     /// Per-tool permission rules for granular control over which tool actions
     /// require confirmation.
     ///

crates/settings_ui/src/page_data.rs 🔗

@@ -7516,6 +7516,24 @@ fn ai_page(cx: &App) -> SettingsPage {
                 metadata: None,
                 files: USER,
             }),
+            SettingsPageItem::SettingItem(SettingItem {
+                title: "Show Merge Conflict Indicator",
+                description: "Whether to show the merge conflict indicator in the status bar that offers to resolve conflicts using the agent.",
+                field: Box::new(SettingField {
+                    json_path: Some("agent.show_merge_conflict_indicator"),
+                    pick: |settings_content| {
+                        settings_content.agent.as_ref()?.show_merge_conflict_indicator.as_ref()
+                    },
+                    write: |settings_content, value| {
+                        settings_content
+                            .agent
+                            .get_or_insert_default()
+                            .show_merge_conflict_indicator = value;
+                    },
+                }),
+                metadata: None,
+                files: USER,
+            }),
         ]);
 
         items.into_boxed_slice()

crates/workspace/src/workspace.rs 🔗

@@ -7693,11 +7693,6 @@ impl GlobalAnyActiveCall {
     }
 }
 
-pub fn merge_conflict_notification_id() -> NotificationId {
-    struct MergeConflictNotification;
-    NotificationId::unique::<MergeConflictNotification>()
-}
-
 /// Workspace-local view of a remote participant's location.
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 pub enum ParticipantLocation {

crates/zed/src/zed.rs 🔗

@@ -502,12 +502,15 @@ pub fn initialize_workspace(app_state: Arc<AppState>, cx: &mut App) {
             cx.new(|_| go_to_line::cursor_position::CursorPosition::new(workspace));
         let line_ending_indicator =
             cx.new(|_| line_ending_selector::LineEndingIndicator::default());
+        let merge_conflict_indicator =
+            cx.new(|cx| git_ui::MergeConflictIndicator::new(workspace, cx));
         workspace.status_bar().update(cx, |status_bar, cx| {
             status_bar.add_left_item(search_button, window, cx);
             status_bar.add_left_item(lsp_button, window, cx);
             status_bar.add_left_item(diagnostic_summary, window, cx);
             status_bar.add_left_item(active_file_name, window, cx);
             status_bar.add_left_item(activity_indicator, window, cx);
+            status_bar.add_left_item(merge_conflict_indicator, window, cx);
             status_bar.add_right_item(edit_prediction_ui, window, cx);
             status_bar.add_right_item(active_buffer_encoding, window, cx);
             status_bar.add_right_item(active_buffer_language, window, cx);