acp: Display a new version call out when one is available (#37479)

Agus Zubiaga and Cole Miller created

<img width="500" alt="CleanShot 2025-09-03 at 16 13 59@2x"
src="https://github.com/user-attachments/assets/beb91365-28e2-4f87-a2c5-7136d37382c7"></img>



Release Notes:

- Agent Panel: Display a callout when a new version of an external agent
is available

---------

Co-authored-by: Cole Miller <cole@zed.dev>

Change summary

crates/agent_servers/src/agent_servers.rs |  17 +++
crates/agent_servers/src/e2e_tests.rs     |   2 
crates/agent_ui/src/acp/message_editor.rs |   2 
crates/agent_ui/src/acp/thread_view.rs    | 101 +++++++++++++++++++-----
4 files changed, 95 insertions(+), 27 deletions(-)

Detailed changes

crates/agent_servers/src/agent_servers.rs 🔗

@@ -45,11 +45,20 @@ pub fn init(cx: &mut App) {
 pub struct AgentServerDelegate {
     project: Entity<Project>,
     status_tx: Option<watch::Sender<SharedString>>,
+    new_version_available: Option<watch::Sender<Option<String>>>,
 }
 
 impl AgentServerDelegate {
-    pub fn new(project: Entity<Project>, status_tx: Option<watch::Sender<SharedString>>) -> Self {
-        Self { project, status_tx }
+    pub fn new(
+        project: Entity<Project>,
+        status_tx: Option<watch::Sender<SharedString>>,
+        new_version_tx: Option<watch::Sender<Option<String>>>,
+    ) -> Self {
+        Self {
+            project,
+            status_tx,
+            new_version_available: new_version_tx,
+        }
     }
 
     pub fn project(&self) -> &Entity<Project> {
@@ -73,6 +82,7 @@ impl AgentServerDelegate {
             )));
         };
         let status_tx = self.status_tx;
+        let new_version_available = self.new_version_available;
 
         cx.spawn(async move |cx| {
             if !ignore_system_version {
@@ -160,6 +170,9 @@ impl AgentServerDelegate {
                                 )
                                 .await
                                 .log_err();
+                                if let Some(mut new_version_available) = new_version_available {
+                                    new_version_available.send(Some(latest_version)).ok();
+                                }
                             }
                         }
                     })

crates/agent_servers/src/e2e_tests.rs 🔗

@@ -498,7 +498,7 @@ pub async fn new_test_thread(
     current_dir: impl AsRef<Path>,
     cx: &mut TestAppContext,
 ) -> Entity<AcpThread> {
-    let delegate = AgentServerDelegate::new(project.clone(), None);
+    let delegate = AgentServerDelegate::new(project.clone(), None, None);
 
     let connection = cx
         .update(|cx| server.connect(current_dir.as_ref(), delegate, cx))

crates/agent_ui/src/acp/message_editor.rs 🔗

@@ -700,7 +700,7 @@ impl MessageEditor {
             self.project.read(cx).fs().clone(),
             self.history_store.clone(),
         ));
-        let delegate = AgentServerDelegate::new(self.project.clone(), None);
+        let delegate = AgentServerDelegate::new(self.project.clone(), None, None);
         let connection = server.connect(Path::new(""), delegate, cx);
         cx.spawn(async move |_, cx| {
             let agent = connection.await?;

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

@@ -46,7 +46,7 @@ use text::Anchor;
 use theme::ThemeSettings;
 use ui::{
     Callout, CommonAnimationExt, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding,
-    PopoverMenuHandle, Scrollbar, ScrollbarState, SpinnerLabel, Tooltip, prelude::*,
+    PopoverMenuHandle, Scrollbar, ScrollbarState, SpinnerLabel, TintColor, Tooltip, prelude::*,
 };
 use util::{ResultExt, size::format_file_size, time::duration_alt_display};
 use workspace::{CollaboratorId, Workspace};
@@ -288,6 +288,7 @@ pub struct AcpThreadView {
     prompt_capabilities: Rc<Cell<PromptCapabilities>>,
     available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
     is_loading_contents: bool,
+    new_server_version_available: Option<SharedString>,
     _cancel_task: Option<Task<()>>,
     _subscriptions: [Subscription; 3],
 }
@@ -416,9 +417,23 @@ impl AcpThreadView {
             _subscriptions: subscriptions,
             _cancel_task: None,
             focus_handle: cx.focus_handle(),
+            new_server_version_available: None,
         }
     }
 
+    fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.thread_state = Self::initial_state(
+            self.agent.clone(),
+            None,
+            self.workspace.clone(),
+            self.project.clone(),
+            window,
+            cx,
+        );
+        self.new_server_version_available.take();
+        cx.notify();
+    }
+
     fn initial_state(
         agent: Rc<dyn AgentServer>,
         resume_thread: Option<DbThreadMetadata>,
@@ -451,8 +466,13 @@ impl AcpThreadView {
             })
             .next()
             .unwrap_or_else(|| paths::home_dir().as_path().into());
-        let (tx, mut rx) = watch::channel("Loading…".into());
-        let delegate = AgentServerDelegate::new(project.clone(), Some(tx));
+        let (status_tx, mut status_rx) = watch::channel("Loading…".into());
+        let (new_version_available_tx, mut new_version_available_rx) = watch::channel(None);
+        let delegate = AgentServerDelegate::new(
+            project.clone(),
+            Some(status_tx),
+            Some(new_version_available_tx),
+        );
 
         let connect_task = agent.connect(&root_dir, delegate, cx);
         let load_task = cx.spawn_in(window, async move |this, cx| {
@@ -627,10 +647,23 @@ impl AcpThreadView {
             .log_err();
         });
 
+        cx.spawn(async move |this, cx| {
+            while let Ok(new_version) = new_version_available_rx.recv().await {
+                if let Some(new_version) = new_version {
+                    this.update(cx, |this, cx| {
+                        this.new_server_version_available = Some(new_version.into());
+                        cx.notify();
+                    })
+                    .log_err();
+                }
+            }
+        })
+        .detach();
+
         let loading_view = cx.new(|cx| {
             let update_title_task = cx.spawn(async move |this, cx| {
                 loop {
-                    let status = rx.recv().await?;
+                    let status = status_rx.recv().await?;
                     this.update(cx, |this: &mut LoadingView, cx| {
                         this.title = status;
                         cx.notify();
@@ -672,15 +705,7 @@ impl AcpThreadView {
                             .map_or(false, |provider| provider.is_authenticated(cx))
                     {
                         this.update(cx, |this, cx| {
-                            this.thread_state = Self::initial_state(
-                                agent.clone(),
-                                None,
-                                this.workspace.clone(),
-                                this.project.clone(),
-                                window,
-                                cx,
-                            );
-                            cx.notify();
+                            this.reset(window, cx);
                         })
                         .ok();
                     }
@@ -1443,7 +1468,6 @@ impl AcpThreadView {
         cx.notify();
         self.auth_task =
             Some(cx.spawn_in(window, {
-                let project = self.project.clone();
                 let agent = self.agent.clone();
                 async move |this, cx| {
                     let result = authenticate.await;
@@ -1472,14 +1496,7 @@ impl AcpThreadView {
                             }
                             this.handle_thread_error(err, cx);
                         } else {
-                            this.thread_state = Self::initial_state(
-                                agent,
-                                None,
-                                this.workspace.clone(),
-                                project.clone(),
-                                window,
-                                cx,
-                            )
+                            this.reset(window, cx);
                         }
                         this.auth_task.take()
                     })
@@ -1501,7 +1518,7 @@ impl AcpThreadView {
         let cwd = project.first_project_directory(cx);
         let shell = project.terminal_settings(&cwd, cx).shell.clone();
 
-        let delegate = AgentServerDelegate::new(project_entity.clone(), None);
+        let delegate = AgentServerDelegate::new(project_entity.clone(), None, None);
         let command = ClaudeCode::login_command(delegate, cx);
 
         window.spawn(cx, async move |cx| {
@@ -4800,6 +4817,38 @@ impl AcpThreadView {
         Some(div().child(content))
     }
 
+    fn render_new_version_callout(&self, version: &SharedString, cx: &mut Context<Self>) -> Div {
+        v_flex().w_full().justify_end().child(
+            h_flex()
+                .p_2()
+                .pr_3()
+                .w_full()
+                .gap_1p5()
+                .border_t_1()
+                .border_color(cx.theme().colors().border)
+                .bg(cx.theme().colors().element_background)
+                .child(
+                    h_flex()
+                        .flex_1()
+                        .gap_1p5()
+                        .child(
+                            Icon::new(IconName::Download)
+                                .color(Color::Accent)
+                                .size(IconSize::Small),
+                        )
+                        .child(Label::new("New version available").size(LabelSize::Small)),
+                )
+                .child(
+                    Button::new("update-button", format!("Update to v{}", version))
+                        .label_size(LabelSize::Small)
+                        .style(ButtonStyle::Tinted(TintColor::Accent))
+                        .on_click(cx.listener(|this, _, window, cx| {
+                            this.reset(window, cx);
+                        })),
+                ),
+        )
+    }
+
     fn get_current_model_name(&self, cx: &App) -> SharedString {
         // For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet")
         // For ACP agents, use the agent name (e.g., "Claude Code", "Gemini CLI")
@@ -5210,6 +5259,12 @@ impl Render for AcpThreadView {
             })
             .children(self.render_thread_retry_status_callout(window, cx))
             .children(self.render_thread_error(window, cx))
+            .when_some(
+                self.new_server_version_available.as_ref().filter(|_| {
+                    !has_messages || !matches!(self.thread_state, ThreadState::Ready { .. })
+                }),
+                |this, version| this.child(self.render_new_version_callout(&version, cx)),
+            )
             .children(
                 if let Some(usage_callout) = self.render_usage_callout(line_height, cx) {
                     Some(usage_callout.into_any_element())