Enable configurable dismissal of language server notifications that do not require user interaction (#46708)

Jens Kouros and Kirill Bulatov created

Closes #38769

Release Notes:

- Dismiss server notifications automatically with
`"global_lsp_settings": { "notifications": { "dismiss_timeout_ms": 5000
} }` settings defaults.

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>

Change summary

assets/settings/default.json           |   5 
crates/project/src/lsp_store.rs        |  69 +++++--
crates/project/src/project.rs          |  16 
crates/project/src/project_settings.rs |  21 ++
crates/settings_content/src/project.rs |  12 +
crates/workspace/src/notifications.rs  | 264 +++++++++++++++++++++++++++
crates/workspace/src/workspace.rs      |  10 
docs/src/reference/all-settings.md     |   9 
8 files changed, 364 insertions(+), 42 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -2236,6 +2236,11 @@
   "global_lsp_settings": {
     // Whether to show the LSP servers button in the status bar.
     "button": true,
+    "notifications": {
+      // Timeout in milliseconds for automatically dismissing language server notifications.
+      // Set to 0 to disable auto-dismiss.
+      "dismiss_timeout_ms": 5000,
+    },
   },
   // Jupyter settings
   "jupyter": {

crates/project/src/lsp_store.rs 🔗

@@ -145,6 +145,7 @@ const SERVER_LAUNCHING_BEFORE_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5
 pub const SERVER_PROGRESS_THROTTLE_TIMEOUT: Duration = Duration::from_millis(100);
 const WORKSPACE_DIAGNOSTICS_TOKEN_START: &str = "id:";
 const SERVER_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(10);
+static NEXT_PROMPT_REQUEST_ID: AtomicUsize = AtomicUsize::new(0);
 
 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
 pub enum ProgressToken {
@@ -1095,18 +1096,19 @@ impl LocalLspStore {
                     async move {
                         let actions = params.actions.unwrap_or_default();
                         let message = params.message.clone();
-                        let (tx, rx) = smol::channel::bounded(1);
-                        let request = LanguageServerPromptRequest {
-                            level: match params.typ {
-                                lsp::MessageType::ERROR => PromptLevel::Critical,
-                                lsp::MessageType::WARNING => PromptLevel::Warning,
-                                _ => PromptLevel::Info,
-                            },
-                            message: params.message,
-                            actions,
-                            response_channel: tx,
-                            lsp_name: name.clone(),
+                        let (tx, rx) = smol::channel::bounded::<MessageActionItem>(1);
+                        let level = match params.typ {
+                            lsp::MessageType::ERROR => PromptLevel::Critical,
+                            lsp::MessageType::WARNING => PromptLevel::Warning,
+                            _ => PromptLevel::Info,
                         };
+                        let request = LanguageServerPromptRequest::new(
+                            level,
+                            params.message,
+                            actions,
+                            name.clone(),
+                            tx,
+                        );
 
                         let did_update = this
                             .update(&mut cx, |_, cx| {
@@ -1141,17 +1143,13 @@ impl LocalLspStore {
                     let mut cx = cx.clone();
 
                     let (tx, _) = smol::channel::bounded(1);
-                    let request = LanguageServerPromptRequest {
-                        level: match params.typ {
-                            lsp::MessageType::ERROR => PromptLevel::Critical,
-                            lsp::MessageType::WARNING => PromptLevel::Warning,
-                            _ => PromptLevel::Info,
-                        },
-                        message: params.message,
-                        actions: vec![],
-                        response_channel: tx,
-                        lsp_name: name,
+                    let level = match params.typ {
+                        lsp::MessageType::ERROR => PromptLevel::Critical,
+                        lsp::MessageType::WARNING => PromptLevel::Warning,
+                        _ => PromptLevel::Info,
                     };
+                    let request =
+                        LanguageServerPromptRequest::new(level, params.message, vec![], name, tx);
 
                     let _ = this.update(&mut cx, |_, cx| {
                         cx.emit(LspStoreEvent::LanguageServerPrompt(request));
@@ -13755,6 +13753,7 @@ struct LspBufferSnapshot {
 /// A prompt requested by LSP server.
 #[derive(Clone, Debug)]
 pub struct LanguageServerPromptRequest {
+    pub id: usize,
     pub level: PromptLevel,
     pub message: String,
     pub actions: Vec<MessageActionItem>,
@@ -13763,6 +13762,23 @@ pub struct LanguageServerPromptRequest {
 }
 
 impl LanguageServerPromptRequest {
+    pub fn new(
+        level: PromptLevel,
+        message: String,
+        actions: Vec<MessageActionItem>,
+        lsp_name: String,
+        response_channel: smol::channel::Sender<MessageActionItem>,
+    ) -> Self {
+        let id = NEXT_PROMPT_REQUEST_ID.fetch_add(1, atomic::Ordering::AcqRel);
+        LanguageServerPromptRequest {
+            id,
+            level,
+            message,
+            actions,
+            lsp_name,
+            response_channel,
+        }
+    }
     pub async fn respond(self, index: usize) -> Option<()> {
         if let Some(response) = self.actions.into_iter().nth(index) {
             self.response_channel.send(response).await.ok()
@@ -13770,6 +13786,17 @@ impl LanguageServerPromptRequest {
             None
         }
     }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn test(
+        level: PromptLevel,
+        message: String,
+        actions: Vec<MessageActionItem>,
+        lsp_name: String,
+    ) -> Self {
+        let (tx, _rx) = smol::channel::unbounded();
+        LanguageServerPromptRequest::new(level, message, actions, lsp_name, tx)
+    }
 }
 impl PartialEq for LanguageServerPromptRequest {
     fn eq(&self, other: &Self) -> bool {

crates/project/src/project.rs 🔗

@@ -4913,13 +4913,15 @@ impl Project {
             })
             .collect();
         this.update(&mut cx, |_, cx| {
-            cx.emit(Event::LanguageServerPrompt(LanguageServerPromptRequest {
-                level: proto_to_prompt(envelope.payload.level.context("Invalid prompt level")?),
-                message: envelope.payload.message,
-                actions: actions.clone(),
-                lsp_name: envelope.payload.lsp_name,
-                response_channel: tx,
-            }));
+            cx.emit(Event::LanguageServerPrompt(
+                LanguageServerPromptRequest::new(
+                    proto_to_prompt(envelope.payload.level.context("Invalid prompt level")?),
+                    envelope.payload.message,
+                    actions.clone(),
+                    envelope.payload.lsp_name,
+                    tx,
+                ),
+            ));
 
             anyhow::Ok(())
         })?;

crates/project/src/project_settings.rs 🔗

@@ -123,6 +123,17 @@ pub struct GlobalLspSettings {
     ///
     /// Default: `true`
     pub button: bool,
+    pub notifications: LspNotificationSettings,
+}
+
+#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, Debug)]
+#[serde(tag = "source", rename_all = "snake_case")]
+pub struct LspNotificationSettings {
+    /// Timeout in milliseconds for automatically dismissing language server notifications.
+    /// Set to 0 to disable auto-dismiss.
+    ///
+    /// Default: 5000
+    pub dismiss_timeout_ms: Option<u64>,
 }
 
 #[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, Debug)]
@@ -614,6 +625,16 @@ impl Settings for ProjectSettings {
                     .unwrap()
                     .button
                     .unwrap(),
+                notifications: LspNotificationSettings {
+                    dismiss_timeout_ms: content
+                        .global_lsp_settings
+                        .as_ref()
+                        .unwrap()
+                        .notifications
+                        .as_ref()
+                        .unwrap()
+                        .dismiss_timeout_ms,
+                },
             },
             dap: project
                 .dap

crates/settings_content/src/project.rs 🔗

@@ -199,6 +199,18 @@ pub struct GlobalLspSettingsContent {
     ///
     /// Default: `true`
     pub button: Option<bool>,
+    /// Settings for language server notifications
+    pub notifications: Option<LspNotificationSettingsContent>,
+}
+
+#[with_fallible_options]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom)]
+pub struct LspNotificationSettingsContent {
+    /// Timeout in milliseconds for automatically dismissing language server notifications.
+    /// Set to 0 to disable auto-dismiss.
+    ///
+    /// Default: 5000
+    pub dismiss_timeout_ms: Option<u64>,
 }
 
 #[with_fallible_options]

crates/workspace/src/notifications.rs 🔗

@@ -1,12 +1,13 @@
 use crate::{SuppressNotification, Toast, Workspace};
 use anyhow::Context as _;
 use gpui::{
-    AnyView, App, AppContext as _, AsyncWindowContext, ClickEvent, Context, DismissEvent, Entity,
-    EventEmitter, FocusHandle, Focusable, PromptLevel, Render, ScrollHandle, Task,
-    TextStyleRefinement, UnderlineStyle, svg,
+    AnyEntity, AnyView, App, AppContext as _, AsyncWindowContext, ClickEvent, Context,
+    DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, PromptLevel, Render, ScrollHandle,
+    Task, TextStyleRefinement, UnderlineStyle, svg,
 };
 use markdown::{Markdown, MarkdownElement, MarkdownStyle};
 use parking_lot::Mutex;
+use project::project_settings::ProjectSettings;
 use settings::Settings;
 use theme::ThemeSettings;
 
@@ -99,6 +100,40 @@ impl Workspace {
                 }
             })
             .detach();
+
+            if let Ok(prompt) =
+                AnyEntity::from(notification.clone()).downcast::<LanguageServerPrompt>()
+            {
+                let is_prompt_without_actions = prompt
+                    .read(cx)
+                    .request
+                    .as_ref()
+                    .is_some_and(|request| request.actions.is_empty());
+
+                let dismiss_timeout_ms = ProjectSettings::get_global(cx)
+                    .global_lsp_settings
+                    .notifications
+                    .dismiss_timeout_ms;
+
+                if is_prompt_without_actions {
+                    if let Some(dismiss_duration_ms) = dismiss_timeout_ms.filter(|&ms| ms > 0) {
+                        let task = cx.spawn({
+                            let id = id.clone();
+                            async move |this, cx| {
+                                cx.background_executor()
+                                    .timer(Duration::from_millis(dismiss_duration_ms))
+                                    .await;
+                                let _ = this.update(cx, |workspace, cx| {
+                                    workspace.dismiss_notification(&id, cx);
+                                });
+                            }
+                        });
+                        prompt.update(cx, |prompt, _| {
+                            prompt.dismiss_task = Some(task);
+                        });
+                    }
+                }
+            }
             notification.into()
         });
     }
@@ -220,6 +255,7 @@ pub struct LanguageServerPrompt {
     request: Option<project::LanguageServerPromptRequest>,
     scroll_handle: ScrollHandle,
     markdown: Entity<Markdown>,
+    dismiss_task: Option<Task<()>>,
 }
 
 impl Focusable for LanguageServerPrompt {
@@ -239,6 +275,7 @@ impl LanguageServerPrompt {
             request: Some(request),
             scroll_handle: ScrollHandle::new(),
             markdown,
+            dismiss_task: None,
         }
     }
 
@@ -253,13 +290,20 @@ impl LanguageServerPrompt {
                 .await
                 .context("Stream already closed")?;
 
-            this.update(cx, |_, cx| cx.emit(DismissEvent));
+            this.update(cx, |this, cx| {
+                this.dismiss_notification(cx);
+            });
 
             anyhow::Ok(())
         })
         .await
         .log_err();
     }
+
+    fn dismiss_notification(&mut self, cx: &mut Context<Self>) {
+        self.dismiss_task = None;
+        cx.emit(DismissEvent);
+    }
 }
 
 impl Render for LanguageServerPrompt {
@@ -334,11 +378,11 @@ impl Render for LanguageServerPrompt {
                                                 }
                                             })
                                             .on_click(cx.listener(
-                                                move |_, _: &ClickEvent, _, cx| {
+                                                move |this, _: &ClickEvent, _, cx| {
                                                     if suppress {
                                                         cx.emit(SuppressEvent);
                                                     } else {
-                                                        cx.emit(DismissEvent);
+                                                        this.dismiss_notification(cx);
                                                     }
                                                 },
                                             )),
@@ -1161,3 +1205,211 @@ where
         self.prompt_err(msg, window, cx, f).detach();
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use fs::FakeFs;
+    use gpui::TestAppContext;
+    use project::{LanguageServerPromptRequest, Project};
+
+    use crate::tests::init_test;
+
+    use super::*;
+
+    #[gpui::test]
+    async fn test_notification_auto_dismiss_with_notifications_from_multiple_language_servers(
+        cx: &mut TestAppContext,
+    ) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs, [], cx).await;
+
+        let (workspace, cx) =
+            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+        let count_notifications = |workspace: &Entity<Workspace>, cx: &mut TestAppContext| {
+            workspace.read_with(cx, |workspace, _| workspace.notification_ids().len())
+        };
+
+        let show_notification = |workspace: &Entity<Workspace>,
+                                 cx: &mut TestAppContext,
+                                 lsp_name: &str| {
+            workspace.update(cx, |workspace, cx| {
+                let request = LanguageServerPromptRequest::test(
+                    gpui::PromptLevel::Warning,
+                    "Test notification".to_string(),
+                    vec![], // Empty actions triggers auto-dismiss
+                    lsp_name.to_string(),
+                );
+                let notification_id = NotificationId::composite::<LanguageServerPrompt>(request.id);
+                workspace.show_notification(notification_id, cx, |cx| {
+                    cx.new(|cx| LanguageServerPrompt::new(request, cx))
+                });
+            })
+        };
+
+        show_notification(&workspace, cx, "Lsp1");
+        assert_eq!(count_notifications(&workspace, cx), 1);
+
+        cx.executor().advance_clock(Duration::from_millis(1000));
+
+        show_notification(&workspace, cx, "Lsp2");
+        assert_eq!(count_notifications(&workspace, cx), 2);
+
+        cx.executor().advance_clock(Duration::from_millis(1000));
+
+        show_notification(&workspace, cx, "Lsp3");
+        assert_eq!(count_notifications(&workspace, cx), 3);
+
+        cx.executor().advance_clock(Duration::from_millis(3000));
+        assert_eq!(count_notifications(&workspace, cx), 2);
+
+        cx.executor().advance_clock(Duration::from_millis(1000));
+        assert_eq!(count_notifications(&workspace, cx), 1);
+
+        cx.executor().advance_clock(Duration::from_millis(1000));
+        assert_eq!(count_notifications(&workspace, cx), 0);
+    }
+
+    #[gpui::test]
+    async fn test_notification_auto_dismiss_with_multiple_notifications_from_single_language_server(
+        cx: &mut TestAppContext,
+    ) {
+        init_test(cx);
+
+        let lsp_name = "server1";
+
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs, [], cx).await;
+        let (workspace, cx) =
+            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+        let count_notifications = |workspace: &Entity<Workspace>, cx: &mut TestAppContext| {
+            workspace.read_with(cx, |workspace, _| workspace.notification_ids().len())
+        };
+
+        let show_notification = |lsp_name: &str,
+                                 workspace: &Entity<Workspace>,
+                                 cx: &mut TestAppContext| {
+            workspace.update(cx, |workspace, cx| {
+                let lsp_name = lsp_name.to_string();
+                let request = LanguageServerPromptRequest::test(
+                    gpui::PromptLevel::Warning,
+                    "Test notification".to_string(),
+                    vec![], // Empty actions triggers auto-dismiss
+                    lsp_name,
+                );
+                let notification_id = NotificationId::composite::<LanguageServerPrompt>(request.id);
+
+                workspace.show_notification(notification_id, cx, |cx| {
+                    cx.new(|cx| LanguageServerPrompt::new(request, cx))
+                });
+            })
+        };
+
+        show_notification(lsp_name, &workspace, cx);
+        assert_eq!(count_notifications(&workspace, cx), 1);
+
+        cx.executor().advance_clock(Duration::from_millis(1000));
+
+        show_notification(lsp_name, &workspace, cx);
+        assert_eq!(count_notifications(&workspace, cx), 2);
+
+        cx.executor().advance_clock(Duration::from_millis(4000));
+        assert_eq!(count_notifications(&workspace, cx), 1);
+
+        cx.executor().advance_clock(Duration::from_millis(1000));
+        assert_eq!(count_notifications(&workspace, cx), 0);
+    }
+
+    #[gpui::test]
+    async fn test_notification_auto_dismiss_turned_off(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        cx.update(|cx| {
+            let mut settings = ProjectSettings::get_global(cx).clone();
+            settings
+                .global_lsp_settings
+                .notifications
+                .dismiss_timeout_ms = Some(0);
+            ProjectSettings::override_global(settings, cx);
+        });
+
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs, [], cx).await;
+        let (workspace, cx) =
+            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+        let count_notifications = |workspace: &Entity<Workspace>, cx: &mut TestAppContext| {
+            workspace.read_with(cx, |workspace, _| workspace.notification_ids().len())
+        };
+
+        workspace.update(cx, |workspace, cx| {
+            let request = LanguageServerPromptRequest::test(
+                gpui::PromptLevel::Warning,
+                "Test notification".to_string(),
+                vec![], // Empty actions would trigger auto-dismiss if enabled
+                "test_server".to_string(),
+            );
+            let notification_id = NotificationId::composite::<LanguageServerPrompt>(request.id);
+            workspace.show_notification(notification_id, cx, |cx| {
+                cx.new(|cx| LanguageServerPrompt::new(request, cx))
+            });
+        });
+
+        assert_eq!(count_notifications(&workspace, cx), 1);
+
+        // Advance time beyond the default auto-dismiss duration
+        cx.executor().advance_clock(Duration::from_millis(10000));
+        assert_eq!(count_notifications(&workspace, cx), 1);
+    }
+
+    #[gpui::test]
+    async fn test_notification_auto_dismiss_with_custom_duration(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let custom_duration_ms: u64 = 2000;
+        cx.update(|cx| {
+            let mut settings = ProjectSettings::get_global(cx).clone();
+            settings
+                .global_lsp_settings
+                .notifications
+                .dismiss_timeout_ms = Some(custom_duration_ms);
+            ProjectSettings::override_global(settings, cx);
+        });
+
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs, [], cx).await;
+        let (workspace, cx) =
+            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+        let count_notifications = |workspace: &Entity<Workspace>, cx: &mut TestAppContext| {
+            workspace.read_with(cx, |workspace, _| workspace.notification_ids().len())
+        };
+
+        workspace.update(cx, |workspace, cx| {
+            let request = LanguageServerPromptRequest::test(
+                gpui::PromptLevel::Warning,
+                "Test notification".to_string(),
+                vec![], // Empty actions triggers auto-dismiss
+                "test_server".to_string(),
+            );
+            let notification_id = NotificationId::composite::<LanguageServerPrompt>(request.id);
+            workspace.show_notification(notification_id, cx, |cx| {
+                cx.new(|cx| LanguageServerPrompt::new(request, cx))
+            });
+        });
+
+        assert_eq!(count_notifications(&workspace, cx), 1);
+
+        // Advance time less than custom duration
+        cx.executor()
+            .advance_clock(Duration::from_millis(custom_duration_ms - 500));
+        assert_eq!(count_notifications(&workspace, cx), 1);
+
+        // Advance time past the custom duration
+        cx.executor().advance_clock(Duration::from_millis(1000));
+        assert_eq!(count_notifications(&workspace, cx), 0);
+    }
+}

crates/workspace/src/workspace.rs 🔗

@@ -104,9 +104,9 @@ use std::{
     borrow::Cow,
     cell::RefCell,
     cmp,
-    collections::{VecDeque, hash_map::DefaultHasher},
+    collections::VecDeque,
     env,
-    hash::{Hash, Hasher},
+    hash::Hash,
     path::{Path, PathBuf},
     process::ExitStatus,
     rc::Rc,
@@ -1359,12 +1359,8 @@ impl Workspace {
                 project::Event::LanguageServerPrompt(request) => {
                     struct LanguageServerPrompt;
 
-                    let mut hasher = DefaultHasher::new();
-                    request.lsp_name.as_str().hash(&mut hasher);
-                    let id = hasher.finish();
-
                     this.show_notification(
-                        NotificationId::composite::<LanguageServerPrompt>(id as usize),
+                        NotificationId::composite::<LanguageServerPrompt>(request.id),
                         cx,
                         |cx| {
                             cx.new(|cx| {

docs/src/reference/all-settings.md 🔗

@@ -1598,7 +1598,12 @@ While other options may be changed at a runtime and should be placed under `sett
 ```json [settings]
 {
   "global_lsp_settings": {
-    "button": true
+    "button": true,
+    "notifications": {
+      // Timeout in milliseconds for automatically dismissing language server notifications.
+      // Set to 0 to disable auto-dismiss.
+      "dismiss_timeout_ms": 5000
+    }
   }
 }
 ```
@@ -1606,6 +1611,8 @@ While other options may be changed at a runtime and should be placed under `sett
 **Options**
 
 - `button`: Whether to show the LSP status button in the status bar
+- `notifications`: Notification-related settings.
+  - `dismiss_timeout_ms`: Timeout in milliseconds for automatically dismissing language server notifications. Set to 0 to disable auto-dismiss.
 
 ## LSP Highlight Debounce