From efb73d7796693d34bc955fa42b22de15b06528b4 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 26 Mar 2026 01:29:52 +0100 Subject: [PATCH] agent_ui: Add agent connection restart controls (#52401) 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. image Release Notes: - acp: Allow for restarting agent servers from the Agent Settings panel. --------- Co-authored-by: MrSubidubi Co-authored-by: Danilo Leal --- 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 + .../ui/src/components/ai/ai_setting_item.rs | 406 ++++++++++++ .../ui/src/components/icon/icon_decoration.rs | 16 +- 6 files changed, 806 insertions(+), 385 deletions(-) create mode 100644 crates/ui/src/components/ai/ai_setting_item.rs diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index fc5a78dfc936617f3782eae154b6a13531e5c425..5f71df75d6287822c77eedfcb2f8fb96487b7950 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/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, language_registry: Arc, agent_server_store: Entity, + agent_connection_store: Entity, workspace: WeakEntity, focus_handle: FocusHandle, configuration_views_by_provider: HashMap, context_server_store: Entity, expanded_provider_configurations: HashMap, context_server_registry: Entity, - _registry_subscription: Subscription, + _subscriptions: Vec, scroll_handle: ScrollHandle, - _check_for_gemini: Task<()>, } impl AgentConfiguration { pub fn new( fs: Arc, agent_server_store: Entity, + agent_connection_store: Entity, context_server_store: Entity, context_server_registry: Entity, language_registry: Arc, @@ -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 = 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) -> 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::>(); - 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 = + 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)) } } diff --git a/crates/agent_ui/src/agent_connection_store.rs b/crates/agent_ui/src/agent_connection_store.rs index 89b3b0ef16f46753a747b1e06a9b9e4a76e839e8..55b8c1493cc990310dc13253ce33cbc4b71a748f 100644 --- a/crates/agent_ui/src/agent_connection_store.rs +++ b/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>, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AgentConnectionStatus { + Disconnected, + Connecting, + Connected, +} + impl AgentConnectionEntry { pub fn wait_for_connection(&self) -> Shared>> { 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, + cx: &mut Context, + ) -> Entity { + 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, cx: &mut Context, ) -> Entity { - 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( diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index fcc141c85db6a8698b17f2b97336c11b6e67bf34..1015e87cef976be5584d7cb7607fd52f216babd6 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/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::>(); + 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()); diff --git a/crates/ui/src/components/ai.rs b/crates/ui/src/components/ai.rs index a31db264e985b3adbca26b9e8d3fb2bdca306dcb..e3ad1db794902ae28b28274a60e3593efb3be392 100644 --- a/crates/ui/src/components/ai.rs +++ b/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::*; diff --git a/crates/ui/src/components/ai/ai_setting_item.rs b/crates/ui/src/components/ai/ai_setting_item.rs new file mode 100644 index 0000000000000000000000000000000000000000..bfb55e4c7da688b736b4ff5c64a5767f1e930120 --- /dev/null +++ b/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 { + 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, + label: SharedString, + detail_label: Option, + actions: Vec, + details: Option, +} + +impl AiSettingItem { + pub fn new( + id: impl Into, + label: impl Into, + 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) -> 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 { + 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()) + } +} diff --git a/crates/ui/src/components/icon/icon_decoration.rs b/crates/ui/src/components/icon/icon_decoration.rs index 9f84a8bcf4eb10672161ed2733d7ed5baa95f899..423f6d73a68e8ee3aea550129e2a6220a8a699a6 100644 --- a/crates/ui/src/components/icon/icon_decoration.rs +++ b/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, group_name: Option, } @@ -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) -> 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)