agent_ui: Add agent connection restart controls (#52401)

Ben Brandt , MrSubidubi , and Danilo Leal created

Track agent connection status in the configuration UI, show a
restart action for connected custom agents, and only render the
External Agents menu section when entries exist.

<img width="505" height="218" alt="image"
src="https://github.com/user-attachments/assets/117edd94-dd06-4b1f-a530-308b7219404b"
/>


Release Notes:

- acp: Allow for restarting agent servers from the Agent Settings panel.

---------

Co-authored-by: MrSubidubi <dev@bahn.sh>
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>

Change summary

crates/agent_ui/src/agent_configuration.rs       | 587 +++++++----------
crates/agent_ui/src/agent_connection_store.rs    | 174 +++-
crates/agent_ui/src/agent_panel.rs               |   6 
crates/ui/src/components/ai.rs                   |   2 
crates/ui/src/components/ai/ai_setting_item.rs   | 406 ++++++++++++
crates/ui/src/components/icon/icon_decoration.rs |  16 
6 files changed, 806 insertions(+), 385 deletions(-)

Detailed changes

crates/agent_ui/src/agent_configuration.rs đź”—

@@ -4,7 +4,7 @@ mod configure_context_server_tools_modal;
 mod manage_profiles_modal;
 mod tool_picker;
 
-use std::{ops::Range, sync::Arc};
+use std::{ops::Range, rc::Rc, sync::Arc};
 
 use agent::ContextServerRegistry;
 use anyhow::Result;
@@ -33,9 +33,9 @@ use project::{
 };
 use settings::{Settings, SettingsStore, update_settings_file};
 use ui::{
-    ButtonStyle, Chip, CommonAnimationExt, ContextMenu, ContextMenuEntry, Disclosure, Divider,
-    DividerColor, ElevationIndex, Indicator, LabelSize, PopoverMenu, Switch, Tooltip,
-    WithScrollbar, prelude::*,
+    AiSettingItem, AiSettingItemSource, AiSettingItemStatus, ButtonStyle, Chip, ContextMenu,
+    ContextMenuEntry, Disclosure, Divider, DividerColor, ElevationIndex, LabelSize, PopoverMenu,
+    Switch, Tooltip, WithScrollbar, prelude::*,
 };
 use util::ResultExt as _;
 use workspace::{Workspace, create_and_open_local_file};
@@ -45,29 +45,32 @@ pub(crate) use configure_context_server_modal::ConfigureContextServerModal;
 pub(crate) use configure_context_server_tools_modal::ConfigureContextServerToolsModal;
 pub(crate) use manage_profiles_modal::ManageProfilesModal;
 
-use crate::agent_configuration::add_llm_provider_modal::{
-    AddLlmProviderModal, LlmCompatibleProvider,
+use crate::{
+    Agent,
+    agent_configuration::add_llm_provider_modal::{AddLlmProviderModal, LlmCompatibleProvider},
+    agent_connection_store::{AgentConnectionStatus, AgentConnectionStore},
 };
 
 pub struct AgentConfiguration {
     fs: Arc<dyn Fs>,
     language_registry: Arc<LanguageRegistry>,
     agent_server_store: Entity<AgentServerStore>,
+    agent_connection_store: Entity<AgentConnectionStore>,
     workspace: WeakEntity<Workspace>,
     focus_handle: FocusHandle,
     configuration_views_by_provider: HashMap<LanguageModelProviderId, AnyView>,
     context_server_store: Entity<ContextServerStore>,
     expanded_provider_configurations: HashMap<LanguageModelProviderId, bool>,
     context_server_registry: Entity<ContextServerRegistry>,
-    _registry_subscription: Subscription,
+    _subscriptions: Vec<Subscription>,
     scroll_handle: ScrollHandle,
-    _check_for_gemini: Task<()>,
 }
 
 impl AgentConfiguration {
     pub fn new(
         fs: Arc<dyn Fs>,
         agent_server_store: Entity<AgentServerStore>,
+        agent_connection_store: Entity<AgentConnectionStore>,
         context_server_store: Entity<ContextServerStore>,
         context_server_registry: Entity<ContextServerRegistry>,
         language_registry: Arc<LanguageRegistry>,
@@ -77,25 +80,27 @@ impl AgentConfiguration {
     ) -> Self {
         let focus_handle = cx.focus_handle();
 
-        let registry_subscription = cx.subscribe_in(
-            &LanguageModelRegistry::global(cx),
-            window,
-            |this, _, event: &language_model::Event, window, cx| match event {
-                language_model::Event::AddedProvider(provider_id) => {
-                    let provider = LanguageModelRegistry::read_global(cx).provider(provider_id);
-                    if let Some(provider) = provider {
-                        this.add_provider_configuration_view(&provider, window, cx);
+        let subscriptions = vec![
+            cx.subscribe_in(
+                &LanguageModelRegistry::global(cx),
+                window,
+                |this, _, event: &language_model::Event, window, cx| match event {
+                    language_model::Event::AddedProvider(provider_id) => {
+                        let provider = LanguageModelRegistry::read_global(cx).provider(provider_id);
+                        if let Some(provider) = provider {
+                            this.add_provider_configuration_view(&provider, window, cx);
+                        }
                     }
-                }
-                language_model::Event::RemovedProvider(provider_id) => {
-                    this.remove_provider_configuration_view(provider_id);
-                }
-                _ => {}
-            },
-        );
-
-        cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify())
-            .detach();
+                    language_model::Event::RemovedProvider(provider_id) => {
+                        this.remove_provider_configuration_view(provider_id);
+                    }
+                    _ => {}
+                },
+            ),
+            cx.subscribe(&agent_server_store, |_, _, _, cx| cx.notify()),
+            cx.observe(&agent_connection_store, |_, _, cx| cx.notify()),
+            cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify()),
+        ];
 
         let mut this = Self {
             fs,
@@ -104,13 +109,14 @@ impl AgentConfiguration {
             focus_handle,
             configuration_views_by_provider: HashMap::default(),
             agent_server_store,
+            agent_connection_store,
             context_server_store,
             expanded_provider_configurations: HashMap::default(),
             context_server_registry,
-            _registry_subscription: registry_subscription,
+            _subscriptions: subscriptions,
             scroll_handle: ScrollHandle::new(),
-            _check_for_gemini: Task::ready(()),
         };
+
         this.build_provider_configuration_views(window, cx);
         this
     }
@@ -636,6 +642,22 @@ impl AgentConfiguration {
             )
         });
 
+        let display_name = if provided_by_extension {
+            resolve_extension_for_context_server(&context_server_id, cx)
+                .map(|(_, manifest)| {
+                    let name = manifest.name.as_str();
+                    let stripped = name
+                        .strip_suffix(" MCP Server")
+                        .or_else(|| name.strip_suffix(" MCP"))
+                        .or_else(|| name.strip_suffix(" Context Server"))
+                        .unwrap_or(name);
+                    SharedString::from(stripped.to_string())
+                })
+                .unwrap_or_else(|| item_id.clone())
+        } else {
+            item_id.clone()
+        };
+
         let error = if let ContextServerStatus::Error(error) = server_status.clone() {
             Some(error)
         } else {
@@ -651,57 +673,19 @@ impl AgentConfiguration {
             .tools_for_server(&context_server_id)
             .count();
 
-        let (source_icon, source_tooltip) = if provided_by_extension {
-            (
-                IconName::ZedSrcExtension,
-                "This MCP server was installed from an extension.",
-            )
+        let source = if provided_by_extension {
+            AiSettingItemSource::Extension
         } else {
-            (
-                IconName::ZedSrcCustom,
-                "This custom MCP server was installed directly.",
-            )
+            AiSettingItemSource::Custom
         };
 
-        let (status_indicator, tooltip_text) = match server_status {
-            ContextServerStatus::Starting => (
-                Icon::new(IconName::LoadCircle)
-                    .size(IconSize::XSmall)
-                    .color(Color::Accent)
-                    .with_keyed_rotate_animation(
-                        SharedString::from(format!("{}-starting", context_server_id.0)),
-                        3,
-                    )
-                    .into_any_element(),
-                "Server is starting.",
-            ),
-            ContextServerStatus::Running => (
-                Indicator::dot().color(Color::Success).into_any_element(),
-                "Server is active.",
-            ),
-            ContextServerStatus::Error(_) => (
-                Indicator::dot().color(Color::Error).into_any_element(),
-                "Server has an error.",
-            ),
-            ContextServerStatus::Stopped => (
-                Indicator::dot().color(Color::Muted).into_any_element(),
-                "Server is stopped.",
-            ),
-            ContextServerStatus::AuthRequired => (
-                Indicator::dot().color(Color::Warning).into_any_element(),
-                "Authentication required.",
-            ),
-            ContextServerStatus::Authenticating => (
-                Icon::new(IconName::LoadCircle)
-                    .size(IconSize::XSmall)
-                    .color(Color::Accent)
-                    .with_keyed_rotate_animation(
-                        SharedString::from(format!("{}-authenticating", context_server_id.0)),
-                        3,
-                    )
-                    .into_any_element(),
-                "Waiting for authorization...",
-            ),
+        let status = match server_status {
+            ContextServerStatus::Starting => AiSettingItemStatus::Starting,
+            ContextServerStatus::Running => AiSettingItemStatus::Running,
+            ContextServerStatus::Error(_) => AiSettingItemStatus::Error,
+            ContextServerStatus::Stopped => AiSettingItemStatus::Stopped,
+            ContextServerStatus::AuthRequired => AiSettingItemStatus::AuthRequired,
+            ContextServerStatus::Authenticating => AiSettingItemStatus::Authenticating,
         };
 
         let is_remote = server_configuration
@@ -845,232 +829,165 @@ impl AgentConfiguration {
         let feedback_base_container =
             || h_flex().py_1().min_w_0().w_full().gap_1().justify_between();
 
-        v_flex()
-            .min_w_0()
-            .id(item_id.clone())
-            .child(
-                h_flex()
-                    .min_w_0()
-                    .w_full()
-                    .justify_between()
+        let details: Option<AnyElement> = if let Some(error) = error {
+            Some(
+                feedback_base_container()
                     .child(
                         h_flex()
-                            .flex_1()
+                            .pr_4()
                             .min_w_0()
+                            .w_full()
+                            .gap_2()
                             .child(
-                                h_flex()
-                                    .id(format!("tooltip-{}", item_id))
-                                    .h_full()
-                                    .w_3()
-                                    .mr_2()
-                                    .justify_center()
-                                    .tooltip(Tooltip::text(tooltip_text))
-                                    .child(status_indicator),
-                            )
-                            .child(Label::new(item_id).flex_shrink_0().truncate())
-                            .child(
-                                div()
-                                    .id("extension-source")
-                                    .min_w_0()
-                                    .mt_0p5()
-                                    .mx_1()
-                                    .tooltip(Tooltip::text(source_tooltip))
-                                    .child(
-                                        Icon::new(source_icon)
-                                            .size(IconSize::Small)
-                                            .color(Color::Muted),
-                                    ),
+                                Icon::new(IconName::XCircle)
+                                    .size(IconSize::XSmall)
+                                    .color(Color::Error),
                             )
-                            .when(is_running, |this| {
-                                this.child(
-                                    Label::new(if tool_count == 1 {
-                                        SharedString::from("1 tool")
-                                    } else {
-                                        SharedString::from(format!("{} tools", tool_count))
-                                    })
-                                    .color(Color::Muted)
-                                    .size(LabelSize::Small),
-                                )
-                            }),
+                            .child(div().min_w_0().flex_1().child(
+                                Label::new(error).color(Color::Muted).size(LabelSize::Small),
+                            )),
                     )
-                    .child(
-                        h_flex()
-                            .gap_0p5()
-                            .flex_none()
-                            .child(context_server_configuration_menu)
-                            .child(
-                            Switch::new("context-server-switch", is_running.into())
+                    .when(should_show_logout_button, |this| {
+                        this.child(
+                            Button::new("error-logout-server", "Log Out")
+                                .style(ButtonStyle::Outlined)
+                                .label_size(LabelSize::Small)
                                 .on_click({
-                                    let context_server_manager = self.context_server_store.clone();
-                                    let fs = self.fs.clone();
+                                    let context_server_store = context_server_store.clone();
                                     let context_server_id = context_server_id.clone();
-
-                                    move |state, _window, cx| {
-                                        let is_enabled = match state {
-                                            ToggleState::Unselected
-                                            | ToggleState::Indeterminate => {
-                                                context_server_manager.update(cx, |this, cx| {
-                                                    this.stop_server(&context_server_id, cx)
-                                                        .log_err();
-                                                });
-                                                false
-                                            }
-                                            ToggleState::Selected => {
-                                                context_server_manager.update(cx, |this, cx| {
-                                                    if let Some(server) =
-                                                        this.get_server(&context_server_id)
-                                                    {
-                                                        this.start_server(server, cx);
-                                                    }
-                                                });
-                                                true
-                                            }
-                                        };
-                                        update_settings_file(fs.clone(), cx, {
-                                            let context_server_id = context_server_id.clone();
-
-                                            move |settings, _| {
-                                                settings
-                                                    .project
-                                                    .context_servers
-                                                    .entry(context_server_id.0)
-                                                    .or_insert_with(|| {
-                                                        settings::ContextServerSettingsContent::Extension {
-                                                            enabled: is_enabled,
-                                                            remote: false,
-                                                            settings: serde_json::json!({}),
-                                                        }
-                                                    })
-                                                    .set_enabled(is_enabled);
-                                            }
+                                    move |_event, _window, cx| {
+                                        context_server_store.update(cx, |store, cx| {
+                                            store.logout_server(&context_server_id, cx).log_err();
                                         });
                                     }
                                 }),
-                        ),
-                    ),
+                        )
+                    })
+                    .into_any_element(),
             )
-            .map(|parent| {
-                if let Some(error) = error {
-                    return parent
-                        .child(
-                            feedback_base_container()
-                                .child(
-                                    h_flex()
-                                        .pr_4()
-                                        .min_w_0()
-                                        .w_full()
-                                        .gap_2()
-                                        .child(
-                                            Icon::new(IconName::XCircle)
-                                                .size(IconSize::XSmall)
-                                                .color(Color::Error),
-                                        )
-                                        .child(
-                                            div().min_w_0().flex_1().child(
-                                                Label::new(error)
-                                                    .color(Color::Muted)
-                                                    .size(LabelSize::Small),
-                                            ),
-                                        ),
-                                )
-                                .when(should_show_logout_button, |this| {
-                                    this.child(
-                                        Button::new("error-logout-server", "Log Out")
-                                            .style(ButtonStyle::Outlined)
-                                            .label_size(LabelSize::Small)
-                                            .on_click({
-                                                let context_server_store =
-                                                    context_server_store.clone();
-                                                let context_server_id =
-                                                    context_server_id.clone();
-                                                move |_event, _window, cx| {
-                                                    context_server_store.update(
-                                                        cx,
-                                                        |store, cx| {
-                                                            store
-                                                                .logout_server(
-                                                                    &context_server_id,
-                                                                    cx,
-                                                                )
-                                                                .log_err();
-                                                        },
-                                                    );
-                                                }
-                                            }),
-                                    )
-                                }),
-                        );
-                }
-                if auth_required {
-                    return parent.child(
-                        feedback_base_container()
-                            .child(
-                                h_flex()
-                                    .pr_4()
-                                    .min_w_0()
-                                    .w_full()
-                                    .gap_2()
-                                    .child(
-                                        Icon::new(IconName::Info)
-                                            .size(IconSize::XSmall)
-                                            .color(Color::Muted),
-                                    )
-                                    .child(
-                                        Label::new("Authenticate to connect this server")
-                                            .color(Color::Muted)
-                                            .size(LabelSize::Small),
-                                    ),
-                            )
-                            .child(
-                                Button::new("error-logout-server", "Authenticate")
-                                    .style(ButtonStyle::Outlined)
-                                    .label_size(LabelSize::Small)
-                                    .on_click({
-                                        let context_server_store = context_server_store.clone();
-                                        let context_server_id = context_server_id.clone();
-                                        move |_event, _window, cx| {
-                                            context_server_store.update(cx, |store, cx| {
-                                                store
-                                                    .authenticate_server(&context_server_id, cx)
-                                                    .log_err();
-                                            });
-                                        }
-                                    }),
-                            ),
-                    );
-                }
-                if authenticating {
-                    return parent.child(
+        } else if auth_required {
+            Some(
+                feedback_base_container()
+                    .child(
                         h_flex()
-                            .mt_1()
                             .pr_4()
                             .min_w_0()
                             .w_full()
                             .gap_2()
                             .child(
-                                div().size_3().flex_shrink_0(), // Alignment Div
+                                Icon::new(IconName::Info)
+                                    .size(IconSize::XSmall)
+                                    .color(Color::Muted),
                             )
                             .child(
-                                Label::new("Authenticating…")
+                                Label::new("Authenticate to connect this server")
                                     .color(Color::Muted)
                                     .size(LabelSize::Small),
                             ),
+                    )
+                    .child(
+                        Button::new("error-logout-server", "Authenticate")
+                            .style(ButtonStyle::Outlined)
+                            .label_size(LabelSize::Small)
+                            .on_click({
+                                let context_server_id = context_server_id.clone();
+                                move |_event, _window, cx| {
+                                    context_server_store.update(cx, |store, cx| {
+                                        store.authenticate_server(&context_server_id, cx).log_err();
+                                    });
+                                }
+                            }),
+                    )
+                    .into_any_element(),
+            )
+        } else if authenticating {
+            Some(
+                h_flex()
+                    .mt_1()
+                    .pr_4()
+                    .min_w_0()
+                    .w_full()
+                    .gap_2()
+                    .child(div().size_3().flex_shrink_0())
+                    .child(
+                        Label::new("Authenticating…")
+                            .color(Color::Muted)
+                            .size(LabelSize::Small),
+                    )
+                    .into_any_element(),
+            )
+        } else {
+            None
+        };
 
-                    );
-                }
-                parent
+        let tool_label = if is_running {
+            Some(if tool_count == 1 {
+                SharedString::from("1 tool")
+            } else {
+                SharedString::from(format!("{} tools", tool_count))
             })
+        } else {
+            None
+        };
+
+        AiSettingItem::new(item_id, display_name, status, source)
+            .action(context_server_configuration_menu)
+            .action(
+                Switch::new("context-server-switch", is_running.into()).on_click({
+                    let context_server_manager = self.context_server_store.clone();
+                    let fs = self.fs.clone();
+
+                    move |state, _window, cx| {
+                        let is_enabled = match state {
+                            ToggleState::Unselected | ToggleState::Indeterminate => {
+                                context_server_manager.update(cx, |this, cx| {
+                                    this.stop_server(&context_server_id, cx).log_err();
+                                });
+                                false
+                            }
+                            ToggleState::Selected => {
+                                context_server_manager.update(cx, |this, cx| {
+                                    if let Some(server) = this.get_server(&context_server_id) {
+                                        this.start_server(server, cx);
+                                    }
+                                });
+                                true
+                            }
+                        };
+                        update_settings_file(fs.clone(), cx, {
+                            let context_server_id = context_server_id.clone();
+
+                            move |settings, _| {
+                                settings
+                                    .project
+                                    .context_servers
+                                    .entry(context_server_id.0)
+                                    .or_insert_with(|| {
+                                        settings::ContextServerSettingsContent::Extension {
+                                            enabled: is_enabled,
+                                            remote: false,
+                                            settings: serde_json::json!({}),
+                                        }
+                                    })
+                                    .set_enabled(is_enabled);
+                            }
+                        });
+                    }
+                }),
+            )
+            .when_some(tool_label, |this, label| this.detail_label(label))
+            .when_some(details, |this, details| this.details(details))
     }
 
     fn render_agent_servers_section(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
         let agent_server_store = self.agent_server_store.read(cx);
 
-        let user_defined_agents = agent_server_store
+        let agents = agent_server_store
             .external_agents()
             .cloned()
             .collect::<Vec<_>>();
 
-        let user_defined_agents: Vec<_> = user_defined_agents
+        let agents: Vec<_> = agents
             .into_iter()
             .map(|name| {
                 let icon = if let Some(icon_path) = agent_server_store.agent_icon(&name) {
@@ -1159,24 +1076,31 @@ impl AgentConfiguration {
                         "All agents connected through the Agent Client Protocol.",
                         add_agent_popover.into_any_element(),
                     ))
-                    .child(v_flex().p_4().pt_0().gap_2().map(|mut parent| {
-                        let mut first = true;
-                        for (name, icon, display_name, source) in user_defined_agents {
-                            if !first {
-                                parent = parent
-                                    .child(Divider::horizontal().color(DividerColor::BorderFaded));
-                            }
-                            first = false;
-                            parent = parent.child(self.render_agent_server(
-                                icon,
-                                name,
-                                display_name,
-                                source,
-                                cx,
-                            ));
-                        }
-                        parent
-                    })),
+                    .child(
+                        v_flex()
+                            .p_4()
+                            .pt_0()
+                            .gap_2()
+                            .children(Itertools::intersperse_with(
+                                agents
+                                    .into_iter()
+                                    .map(|(name, icon, display_name, source)| {
+                                        self.render_agent_server(
+                                            icon,
+                                            name,
+                                            display_name,
+                                            source,
+                                            cx,
+                                        )
+                                        .into_any_element()
+                                    }),
+                                || {
+                                    Divider::horizontal()
+                                        .color(DividerColor::BorderFaded)
+                                        .into_any_element()
+                                },
+                            )),
+                    ),
             )
     }
 
@@ -1200,27 +1124,46 @@ impl AgentConfiguration {
                 .color(Color::Muted),
         };
 
-        let source_badge = match source {
-            ExternalAgentSource::Extension => Some((
-                SharedString::new(format!("agent-source-{}", id)),
-                SharedString::from(format!(
-                    "The {} agent was installed from an extension.",
-                    display_name
-                )),
-                IconName::ZedSrcExtension,
-            )),
-            ExternalAgentSource::Registry => Some((
-                SharedString::new(format!("agent-source-{}", id)),
-                SharedString::from(format!(
-                    "The {} agent was installed from the ACP registry.",
-                    display_name
-                )),
-                IconName::AcpRegistry,
-            )),
-            ExternalAgentSource::Custom => None,
+        let source_kind = match source {
+            ExternalAgentSource::Extension => AiSettingItemSource::Extension,
+            ExternalAgentSource::Registry => AiSettingItemSource::Registry,
+            ExternalAgentSource::Custom => AiSettingItemSource::Custom,
         };
 
         let agent_server_name = AgentId(id.clone());
+        let agent = Agent::Custom {
+            id: agent_server_name.clone(),
+        };
+
+        let connection_status = self
+            .agent_connection_store
+            .read(cx)
+            .connection_status(&agent, cx);
+
+        let restart_button = matches!(
+            connection_status,
+            AgentConnectionStatus::Connected | AgentConnectionStatus::Connecting
+        )
+        .then(|| {
+            IconButton::new(
+                SharedString::from(format!("restart-{}", id)),
+                IconName::RotateCw,
+            )
+            .disabled(connection_status == AgentConnectionStatus::Connecting)
+            .icon_color(Color::Muted)
+            .icon_size(IconSize::Small)
+            .tooltip(Tooltip::text("Restart Agent Connection"))
+            .on_click(cx.listener({
+                let agent = agent.clone();
+                move |this, _, _window, cx| {
+                    let server: Rc<dyn agent_servers::AgentServer> =
+                        Rc::new(agent_servers::CustomAgentServer::new(agent.id()));
+                    this.agent_connection_store.update(cx, |store, cx| {
+                        store.restart_connection(agent.clone(), server, cx);
+                    });
+                }
+            }))
+        });
 
         let uninstall_button = match source {
             ExternalAgentSource::Extension => Some(
@@ -1301,32 +1244,16 @@ impl AgentConfiguration {
             }
         };
 
-        h_flex()
-            .gap_1()
-            .justify_between()
-            .child(
-                h_flex()
-                    .gap_1p5()
-                    .child(icon)
-                    .child(Label::new(display_name))
-                    .when_some(source_badge, |this, (tooltip_id, tooltip_message, icon)| {
-                        this.child(
-                            div()
-                                .id(tooltip_id)
-                                .flex_none()
-                                .tooltip(Tooltip::text(tooltip_message))
-                                .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)),
-                        )
-                    })
-                    .child(
-                        Icon::new(IconName::Check)
-                            .color(Color::Success)
-                            .size(IconSize::Small),
-                    ),
-            )
-            .when_some(uninstall_button, |this, uninstall_button| {
-                this.child(uninstall_button)
-            })
+        let status = match connection_status {
+            AgentConnectionStatus::Disconnected => AiSettingItemStatus::Stopped,
+            AgentConnectionStatus::Connecting => AiSettingItemStatus::Starting,
+            AgentConnectionStatus::Connected => AiSettingItemStatus::Running,
+        };
+
+        AiSettingItem::new(id, display_name, status, source_kind)
+            .icon(icon)
+            .when_some(restart_button, |this, button| this.action(button))
+            .when_some(uninstall_button, |this, button| this.action(button))
     }
 }
 

crates/agent_ui/src/agent_connection_store.rs đź”—

@@ -5,7 +5,8 @@ use agent_servers::{AgentServer, AgentServerDelegate};
 use anyhow::Result;
 use collections::HashMap;
 use futures::{FutureExt, future::Shared};
-use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Subscription, Task};
+use gpui::{App, AppContext, Context, Entity, EventEmitter, SharedString, Subscription, Task};
+
 use project::{AgentServerStore, AgentServersUpdated, Project};
 use watch::Receiver;
 
@@ -27,6 +28,13 @@ pub struct AgentConnectedState {
     pub history: Option<Entity<ThreadHistory>>,
 }
 
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum AgentConnectionStatus {
+    Disconnected,
+    Connecting,
+    Connected,
+}
+
 impl AgentConnectionEntry {
     pub fn wait_for_connection(&self) -> Shared<Task<Result<AgentConnectedState, LoadError>>> {
         match self {
@@ -42,6 +50,14 @@ impl AgentConnectionEntry {
             _ => None,
         }
     }
+
+    pub fn status(&self) -> AgentConnectionStatus {
+        match self {
+            AgentConnectionEntry::Connecting { .. } => AgentConnectionStatus::Connecting,
+            AgentConnectionEntry::Connected(_) => AgentConnectionStatus::Connected,
+            AgentConnectionEntry::Error { .. } => AgentConnectionStatus::Disconnected,
+        }
+    }
 }
 
 pub enum AgentConnectionEntryEvent {
@@ -71,66 +87,124 @@ impl AgentConnectionStore {
         self.entries.get(key)
     }
 
+    pub fn connection_status(&self, key: &Agent, cx: &App) -> AgentConnectionStatus {
+        self.entries
+            .get(key)
+            .map(|entry| entry.read(cx).status())
+            .unwrap_or(AgentConnectionStatus::Disconnected)
+    }
+
+    pub fn restart_connection(
+        &mut self,
+        key: Agent,
+        server: Rc<dyn AgentServer>,
+        cx: &mut Context<Self>,
+    ) -> Entity<AgentConnectionEntry> {
+        if let Some(entry) = self.entries.get(&key) {
+            if matches!(entry.read(cx), AgentConnectionEntry::Connecting { .. }) {
+                return entry.clone();
+            }
+        }
+
+        self.entries.remove(&key);
+        self.request_connection(key, server, cx)
+    }
+
     pub fn request_connection(
         &mut self,
         key: Agent,
         server: Rc<dyn AgentServer>,
         cx: &mut Context<Self>,
     ) -> Entity<AgentConnectionEntry> {
-        self.entries.get(&key).cloned().unwrap_or_else(|| {
-            let (mut new_version_rx, connect_task) = self.start_connection(server.clone(), cx);
-            let connect_task = connect_task.shared();
-
-            let entry = cx.new(|_cx| AgentConnectionEntry::Connecting {
-                connect_task: connect_task.clone(),
-            });
-
-            self.entries.insert(key.clone(), entry.clone());
-
-            cx.spawn({
-                let key = key.clone();
-                let entry = entry.clone();
-                async move |this, cx| match connect_task.await {
-                    Ok(connected_state) => {
-                        entry.update(cx, |entry, cx| {
-                            if let AgentConnectionEntry::Connecting { .. } = entry {
-                                *entry = AgentConnectionEntry::Connected(connected_state);
-                                cx.notify();
-                            }
-                        });
-                    }
-                    Err(error) => {
-                        entry.update(cx, |entry, cx| {
-                            if let AgentConnectionEntry::Connecting { .. } = entry {
-                                *entry = AgentConnectionEntry::Error { error };
-                                cx.notify();
-                            }
-                        });
-                        this.update(cx, |this, _cx| this.entries.remove(&key)).ok();
-                    }
+        if let Some(entry) = self.entries.get(&key) {
+            return entry.clone();
+        }
+
+        let (mut new_version_rx, connect_task) = self.start_connection(server, cx);
+        let connect_task = connect_task.shared();
+
+        let entry = cx.new(|_cx| AgentConnectionEntry::Connecting {
+            connect_task: connect_task.clone(),
+        });
+
+        self.entries.insert(key.clone(), entry.clone());
+        cx.notify();
+
+        cx.spawn({
+            let key = key.clone();
+            let entry = entry.downgrade();
+            async move |this, cx| match connect_task.await {
+                Ok(connected_state) => {
+                    this.update(cx, move |this, cx| {
+                        if this.entries.get(&key) != entry.upgrade().as_ref() {
+                            return;
+                        }
+
+                        entry
+                            .update(cx, move |entry, cx| {
+                                if let AgentConnectionEntry::Connecting { .. } = entry {
+                                    *entry = AgentConnectionEntry::Connected(connected_state);
+                                    cx.notify();
+                                }
+                            })
+                            .ok();
+                    })
+                    .ok();
                 }
-            })
-            .detach();
-
-            cx.spawn({
-                let entry = entry.clone();
-                async move |this, cx| {
-                    while let Ok(version) = new_version_rx.recv().await {
-                        if let Some(version) = version {
-                            entry.update(cx, |_entry, cx| {
-                                cx.emit(AgentConnectionEntryEvent::NewVersionAvailable(
-                                    version.clone().into(),
-                                ));
-                            });
-                            this.update(cx, |this, _cx| this.entries.remove(&key)).ok();
+                Err(error) => {
+                    this.update(cx, move |this, cx| {
+                        if this.entries.get(&key) != entry.upgrade().as_ref() {
+                            return;
                         }
-                    }
+
+                        entry
+                            .update(cx, move |entry, cx| {
+                                if let AgentConnectionEntry::Connecting { .. } = entry {
+                                    *entry = AgentConnectionEntry::Error { error };
+                                    cx.notify();
+                                }
+                            })
+                            .ok();
+                        this.entries.remove(&key);
+                        cx.notify();
+                    })
+                    .ok();
                 }
-            })
-            .detach();
+            }
+        })
+        .detach();
+
+        cx.spawn({
+            let entry = entry.downgrade();
+            async move |this, cx| {
+                while let Ok(version) = new_version_rx.recv().await {
+                    let Some(version) = version else {
+                        continue;
+                    };
+
+                    this.update(cx, move |this, cx| {
+                        if this.entries.get(&key) != entry.upgrade().as_ref() {
+                            return;
+                        }
 
-            entry
+                        entry
+                            .update(cx, move |_entry, cx| {
+                                cx.emit(AgentConnectionEntryEvent::NewVersionAvailable(
+                                    version.into(),
+                                ));
+                            })
+                            .ok();
+                        this.entries.remove(&key);
+                        cx.notify();
+                    })
+                    .ok();
+                    break;
+                }
+            }
         })
+        .detach();
+
+        entry
     }
 
     fn handle_agent_servers_updated(

crates/agent_ui/src/agent_panel.rs đź”—

@@ -1689,6 +1689,7 @@ impl AgentPanel {
             AgentConfiguration::new(
                 fs,
                 agent_server_store,
+                self.connection_store.clone(),
                 context_server_store,
                 self.context_server_registry.clone(),
                 self.language_registry.clone(),
@@ -3822,8 +3823,6 @@ impl AgentPanel {
                                     }
                                 }),
                         )
-                        .separator()
-                        .header("External Agents")
                         .map(|mut menu| {
                             let agent_server_store = agent_server_store.read(cx);
                             let registry_store = project::AgentRegistryStore::try_global(cx);
@@ -3854,6 +3853,9 @@ impl AgentPanel {
                                 .sorted_unstable_by_key(|e| e.display_name.to_lowercase())
                                 .collect::<Vec<_>>();
 
+                            if !agent_items.is_empty() {
+                                menu = menu.separator().header("External Agents");
+                            }
                             for item in &agent_items {
                                 let mut entry = ContextMenuEntry::new(item.display_name.clone());
 

crates/ui/src/components/ai.rs đź”—

@@ -1,5 +1,7 @@
+mod ai_setting_item;
 mod configured_api_card;
 mod thread_item;
 
+pub use ai_setting_item::*;
 pub use configured_api_card::*;
 pub use thread_item::*;

crates/ui/src/components/ai/ai_setting_item.rs đź”—

@@ -0,0 +1,406 @@
+use crate::{IconDecoration, IconDecorationKind, Tooltip, prelude::*};
+use gpui::{Animation, AnimationExt, SharedString, pulsating_between};
+use std::time::Duration;
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum AiSettingItemStatus {
+    #[default]
+    Stopped,
+    Starting,
+    Running,
+    Error,
+    AuthRequired,
+    Authenticating,
+}
+
+impl AiSettingItemStatus {
+    fn tooltip_text(&self) -> &'static str {
+        match self {
+            Self::Stopped => "Server is stopped.",
+            Self::Starting => "Server is starting.",
+            Self::Running => "Server is active.",
+            Self::Error => "Server has an error.",
+            Self::AuthRequired => "Authentication required.",
+            Self::Authenticating => "Waiting for authorization…",
+        }
+    }
+
+    fn indicator_color(&self) -> Option<Color> {
+        match self {
+            Self::Stopped => None,
+            Self::Starting | Self::Authenticating => Some(Color::Muted),
+            Self::Running => Some(Color::Success),
+            Self::Error => Some(Color::Error),
+            Self::AuthRequired => Some(Color::Warning),
+        }
+    }
+
+    fn is_animated(&self) -> bool {
+        matches!(self, Self::Starting | Self::Authenticating)
+    }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum AiSettingItemSource {
+    Extension,
+    Custom,
+    Registry,
+}
+
+impl AiSettingItemSource {
+    fn icon_name(&self) -> IconName {
+        match self {
+            Self::Extension => IconName::ZedSrcExtension,
+            Self::Custom => IconName::ZedSrcCustom,
+            Self::Registry => IconName::AcpRegistry,
+        }
+    }
+
+    fn tooltip_text(&self, label: &str) -> String {
+        match self {
+            Self::Extension => format!("{label} was installed from an extension."),
+            Self::Registry => format!("{label} was installed from the ACP registry."),
+            Self::Custom => format!("{label} was configured manually."),
+        }
+    }
+}
+
+/// A reusable setting item row for AI-related configuration lists.
+#[derive(IntoElement, RegisterComponent)]
+pub struct AiSettingItem {
+    id: ElementId,
+    status: AiSettingItemStatus,
+    source: AiSettingItemSource,
+    icon: Option<AnyElement>,
+    label: SharedString,
+    detail_label: Option<SharedString>,
+    actions: Vec<AnyElement>,
+    details: Option<AnyElement>,
+}
+
+impl AiSettingItem {
+    pub fn new(
+        id: impl Into<ElementId>,
+        label: impl Into<SharedString>,
+        status: AiSettingItemStatus,
+        source: AiSettingItemSource,
+    ) -> Self {
+        Self {
+            id: id.into(),
+            status,
+            source,
+            icon: None,
+            label: label.into(),
+            detail_label: None,
+            actions: Vec::new(),
+            details: None,
+        }
+    }
+
+    pub fn icon(mut self, element: impl IntoElement) -> Self {
+        self.icon = Some(element.into_any_element());
+        self
+    }
+
+    pub fn detail_label(mut self, detail: impl Into<SharedString>) -> Self {
+        self.detail_label = Some(detail.into());
+        self
+    }
+
+    pub fn action(mut self, element: impl IntoElement) -> Self {
+        self.actions.push(element.into_any_element());
+        self
+    }
+
+    pub fn details(mut self, element: impl IntoElement) -> Self {
+        self.details = Some(element.into_any_element());
+        self
+    }
+}
+
+impl RenderOnce for AiSettingItem {
+    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+        let Self {
+            id,
+            status,
+            source,
+            icon,
+            label,
+            detail_label,
+            actions,
+            details,
+        } = self;
+
+        let source_id = format!("source-{}", id);
+        let icon_id = format!("icon-{}", id);
+        let status_tooltip = status.tooltip_text();
+        let source_tooltip = source.tooltip_text(&label);
+
+        let icon_element = icon.unwrap_or_else(|| {
+            let letter = label.chars().next().unwrap_or('?').to_ascii_uppercase();
+
+            h_flex()
+                .size_5()
+                .flex_none()
+                .justify_center()
+                .rounded_sm()
+                .border_1()
+                .border_color(cx.theme().colors().border_variant)
+                .bg(cx.theme().colors().element_active.opacity(0.2))
+                .child(
+                    Label::new(SharedString::from(letter.to_string()))
+                        .size(LabelSize::Small)
+                        .color(Color::Muted)
+                        .buffer_font(cx),
+                )
+                .into_any_element()
+        });
+
+        let icon_child = if status.is_animated() {
+            div()
+                .child(icon_element)
+                .with_animation(
+                    format!("icon-pulse-{}", id),
+                    Animation::new(Duration::from_secs(2))
+                        .repeat()
+                        .with_easing(pulsating_between(0.4, 0.8)),
+                    |element, delta| element.opacity(delta),
+                )
+                .into_any_element()
+        } else {
+            icon_element.into_any_element()
+        };
+
+        let icon_container = div()
+            .id(icon_id)
+            .relative()
+            .flex_none()
+            .tooltip(Tooltip::text(status_tooltip))
+            .child(icon_child)
+            .when_some(status.indicator_color(), |this, color| {
+                this.child(
+                    IconDecoration::new(
+                        IconDecorationKind::Dot,
+                        cx.theme().colors().panel_background,
+                        cx,
+                    )
+                    .size(px(12.))
+                    .color(color.color(cx))
+                    .position(gpui::Point {
+                        x: px(-3.),
+                        y: px(-3.),
+                    }),
+                )
+            });
+
+        v_flex()
+            .id(id)
+            .min_w_0()
+            .child(
+                h_flex()
+                    .min_w_0()
+                    .w_full()
+                    .gap_1p5()
+                    .justify_between()
+                    .child(
+                        h_flex()
+                            .flex_1()
+                            .min_w_0()
+                            .gap_1p5()
+                            .child(icon_container)
+                            .child(Label::new(label).flex_shrink_0().truncate())
+                            .child(
+                                div()
+                                    .id(source_id)
+                                    .min_w_0()
+                                    .flex_none()
+                                    .tooltip(Tooltip::text(source_tooltip))
+                                    .child(
+                                        Icon::new(source.icon_name())
+                                            .size(IconSize::Small)
+                                            .color(Color::Muted),
+                                    ),
+                            )
+                            .when_some(detail_label, |this, detail| {
+                                this.child(
+                                    Label::new(detail)
+                                        .color(Color::Muted)
+                                        .size(LabelSize::Small),
+                                )
+                            }),
+                    )
+                    .when(!actions.is_empty(), |this| {
+                        this.child(h_flex().gap_0p5().flex_none().children(actions))
+                    }),
+            )
+            .children(details)
+    }
+}
+
+impl Component for AiSettingItem {
+    fn scope() -> ComponentScope {
+        ComponentScope::Agent
+    }
+
+    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
+        let container = || {
+            v_flex()
+                .w_80()
+                .p_2()
+                .gap_2()
+                .border_1()
+                .border_color(cx.theme().colors().border_variant)
+                .bg(cx.theme().colors().panel_background)
+        };
+
+        let details_row = |icon_name: IconName, icon_color: Color, message: &str| {
+            h_flex()
+                .py_1()
+                .min_w_0()
+                .w_full()
+                .gap_2()
+                .justify_between()
+                .child(
+                    h_flex()
+                        .pr_4()
+                        .min_w_0()
+                        .w_full()
+                        .gap_2()
+                        .child(
+                            Icon::new(icon_name)
+                                .size(IconSize::XSmall)
+                                .color(icon_color),
+                        )
+                        .child(
+                            div().min_w_0().flex_1().child(
+                                Label::new(SharedString::from(message.to_string()))
+                                    .color(Color::Muted)
+                                    .size(LabelSize::Small),
+                            ),
+                        ),
+                )
+        };
+
+        let examples = vec![
+            single_example(
+                "MCP server with letter avatar (running)",
+                container()
+                    .child(
+                        AiSettingItem::new(
+                            "ext-mcp",
+                            "Postgres",
+                            AiSettingItemStatus::Running,
+                            AiSettingItemSource::Extension,
+                        )
+                        .detail_label("3 tools")
+                        .action(
+                            IconButton::new("menu", IconName::Settings)
+                                .icon_size(IconSize::Small)
+                                .icon_color(Color::Muted),
+                        )
+                        .action(
+                            IconButton::new("toggle", IconName::Check)
+                                .icon_size(IconSize::Small)
+                                .icon_color(Color::Muted),
+                        ),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "MCP server (stopped)",
+                container()
+                    .child(AiSettingItem::new(
+                        "custom-mcp",
+                        "my-local-server",
+                        AiSettingItemStatus::Stopped,
+                        AiSettingItemSource::Custom,
+                    ))
+                    .into_any_element(),
+            ),
+            single_example(
+                "MCP server (starting, animated)",
+                container()
+                    .child(AiSettingItem::new(
+                        "starting-mcp",
+                        "Context7",
+                        AiSettingItemStatus::Starting,
+                        AiSettingItemSource::Extension,
+                    ))
+                    .into_any_element(),
+            ),
+            single_example(
+                "Agent with icon (running)",
+                container()
+                    .child(
+                        AiSettingItem::new(
+                            "ext-agent",
+                            "Claude Agent",
+                            AiSettingItemStatus::Running,
+                            AiSettingItemSource::Extension,
+                        )
+                        .icon(
+                            Icon::new(IconName::AiClaude)
+                                .size(IconSize::Small)
+                                .color(Color::Muted),
+                        )
+                        .action(
+                            IconButton::new("restart", IconName::RotateCw)
+                                .icon_size(IconSize::Small)
+                                .icon_color(Color::Muted),
+                        )
+                        .action(
+                            IconButton::new("delete", IconName::Trash)
+                                .icon_size(IconSize::Small)
+                                .icon_color(Color::Muted),
+                        ),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "Registry agent (starting, animated)",
+                container()
+                    .child(
+                        AiSettingItem::new(
+                            "reg-agent",
+                            "Devin Agent",
+                            AiSettingItemStatus::Starting,
+                            AiSettingItemSource::Registry,
+                        )
+                        .icon(
+                            Icon::new(IconName::ZedAssistant)
+                                .size(IconSize::Small)
+                                .color(Color::Muted),
+                        ),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "Error with details",
+                container()
+                    .child(
+                        AiSettingItem::new(
+                            "error-mcp",
+                            "Amplitude",
+                            AiSettingItemStatus::Error,
+                            AiSettingItemSource::Extension,
+                        )
+                        .details(
+                            details_row(
+                                IconName::XCircle,
+                                Color::Error,
+                                "Failed to connect: connection refused",
+                            )
+                            .child(
+                                Button::new("logout", "Log Out")
+                                    .style(ButtonStyle::Outlined)
+                                    .label_size(LabelSize::Small),
+                            ),
+                        ),
+                    )
+                    .into_any_element(),
+            ),
+        ];
+
+        Some(example_group(examples).vertical().into_any_element())
+    }
+}

crates/ui/src/components/icon/icon_decoration.rs đź”—

@@ -63,6 +63,7 @@ pub struct IconDecoration {
     color: Hsla,
     knockout_color: Hsla,
     knockout_hover_color: Hsla,
+    size: Pixels,
     position: Point<Pixels>,
     group_name: Option<SharedString>,
 }
@@ -78,6 +79,7 @@ impl IconDecoration {
             color,
             knockout_color,
             knockout_hover_color: knockout_color,
+            size: ICON_DECORATION_SIZE,
             position,
             group_name: None,
         }
@@ -116,6 +118,12 @@ impl IconDecoration {
         self
     }
 
+    /// Sets the size of the decoration.
+    pub fn size(mut self, size: Pixels) -> Self {
+        self.size = size;
+        self
+    }
+
     /// Sets the name of the group the decoration belongs to
     pub fn group_name(mut self, name: Option<SharedString>) -> Self {
         self.group_name = name;
@@ -125,11 +133,13 @@ impl IconDecoration {
 
 impl RenderOnce for IconDecoration {
     fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+        let size = self.size;
+
         let foreground = svg()
             .absolute()
             .bottom_0()
             .right_0()
-            .size(ICON_DECORATION_SIZE)
+            .size(size)
             .path(self.kind.fg().path())
             .text_color(self.color);
 
@@ -137,7 +147,7 @@ impl RenderOnce for IconDecoration {
             .absolute()
             .bottom_0()
             .right_0()
-            .size(ICON_DECORATION_SIZE)
+            .size(size)
             .path(self.kind.bg().path())
             .text_color(self.knockout_color)
             .map(|this| match self.group_name {
@@ -148,7 +158,7 @@ impl RenderOnce for IconDecoration {
             });
 
         div()
-            .size(ICON_DECORATION_SIZE)
+            .size(size)
             .flex_none()
             .absolute()
             .bottom(self.position.y)