agent_settings: Add `always_play_sound_when_agent_done` setting (#52284)

ifengqi and Oleksiy Syvokon created

This PR changes `agent.play_sound_when_agent_done` from a boolean to an
enum with three options:

- `never` (default)
- `when_hidden`
- `always`

In Settings → Agent, this now appears as a _Play Sound When Agent Done_
dropdown.

Existing settings are migrated automatically:
- `false` → `never`
- `true` → `always`

### Why

A boolean only allowed the sound to be on or off. This change gives
users clearer control over when the agent notification sound should
play.

### Verification

- Added a migrator test for this setting change.
- Manually tested the settings UI, settings migration and the feature


Release Notes:

- Added new agent notification sound options

---------

Co-authored-by: Oleksiy Syvokon <oleksiy.syvokon@gmail.com>

Change summary

assets/settings/default.json                            |  11 
crates/agent/src/tool_permissions.rs                    |   4 
crates/agent_settings/src/agent_settings.rs             |   8 
crates/agent_ui/src/agent_ui.rs                         |   6 
crates/agent_ui/src/conversation_view.rs                |   2 
crates/migrator/src/migrations.rs                       |   6 
crates/migrator/src/migrations/m_2026_03_30/settings.rs |  29 ++
crates/migrator/src/migrator.rs                         | 127 +++++++++++
crates/settings_content/src/agent.rs                    |  37 ++
crates/settings_ui/src/page_data.rs                     |   2 
crates/settings_ui/src/settings_ui.rs                   |   1 
crates/zed/src/visual_test_runner.rs                    |   4 
docs/src/ai/agent-settings.md                           |   9 
13 files changed, 224 insertions(+), 22 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -1102,11 +1102,14 @@
     // "all_screens" - Show these notifications on all screens
     // "never" - Never show these notifications
     "notify_when_agent_waiting": "primary_screen",
-    // Whether to play a sound when the agent has either completed
+    // When to play a sound when the agent has either completed
     // its response, or needs user input.
-
-    // Default: false
-    "play_sound_when_agent_done": false,
+    // "never" - Never play the sound
+    // "when_hidden" - Only play the sound when the agent panel is not visible
+    // "always" - Always play the sound
+    //
+    // Default: never
+    "play_sound_when_agent_done": "never",
     // Whether to have edit cards in the agent panel expanded, showing a preview of the full diff.
     //
     // Default: true

crates/agent/src/tool_permissions.rs 🔗

@@ -563,7 +563,7 @@ mod tests {
     use crate::tools::{DeletePathTool, EditFileTool, FetchTool, TerminalTool};
     use agent_settings::{AgentProfileId, CompiledRegex, InvalidRegexPattern, ToolRules};
     use gpui::px;
-    use settings::{DockPosition, NotifyWhenAgentWaiting};
+    use settings::{DockPosition, NotifyWhenAgentWaiting, PlaySoundWhenAgentDone};
     use std::sync::Arc;
 
     fn test_agent_settings(tool_permissions: ToolPermissions) -> AgentSettings {
@@ -584,7 +584,7 @@ mod tests {
             default_profile: AgentProfileId::default(),
             profiles: Default::default(),
             notify_when_agent_waiting: NotifyWhenAgentWaiting::default(),
-            play_sound_when_agent_done: false,
+            play_sound_when_agent_done: PlaySoundWhenAgentDone::default(),
             single_file_review: false,
             model_parameters: vec![],
             enable_feedback: false,

crates/agent_settings/src/agent_settings.rs 🔗

@@ -13,8 +13,8 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::{
     DockPosition, DockSide, LanguageModelParameters, LanguageModelSelection, NewThreadLocation,
-    NotifyWhenAgentWaiting, RegisterSetting, Settings, SettingsContent, SettingsStore,
-    SidebarDockPosition, SidebarSide, ThinkingBlockDisplay, ToolPermissionMode,
+    NotifyWhenAgentWaiting, PlaySoundWhenAgentDone, RegisterSetting, Settings, SettingsContent,
+    SettingsStore, SidebarDockPosition, SidebarSide, ThinkingBlockDisplay, ToolPermissionMode,
     update_settings_file,
 };
 
@@ -165,7 +165,7 @@ pub struct AgentSettings {
     pub profiles: IndexMap<AgentProfileId, AgentProfileSettings>,
 
     pub notify_when_agent_waiting: NotifyWhenAgentWaiting,
-    pub play_sound_when_agent_done: bool,
+    pub play_sound_when_agent_done: PlaySoundWhenAgentDone,
     pub single_file_review: bool,
     pub model_parameters: Vec<LanguageModelParameters>,
     pub enable_feedback: bool,
@@ -618,7 +618,7 @@ impl Settings for AgentSettings {
                 .collect(),
 
             notify_when_agent_waiting: agent.notify_when_agent_waiting.unwrap(),
-            play_sound_when_agent_done: agent.play_sound_when_agent_done.unwrap(),
+            play_sound_when_agent_done: agent.play_sound_when_agent_done.unwrap_or_default(),
             single_file_review: agent.single_file_review.unwrap(),
             model_parameters: agent.model_parameters,
             enable_feedback: agent.enable_feedback.unwrap(),

crates/agent_ui/src/agent_ui.rs 🔗

@@ -674,7 +674,9 @@ mod tests {
     use feature_flags::FeatureFlagAppExt;
     use gpui::{BorrowAppContext, TestAppContext, px};
     use project::DisableAiSettings;
-    use settings::{DockPosition, NotifyWhenAgentWaiting, Settings, SettingsStore};
+    use settings::{
+        DockPosition, NotifyWhenAgentWaiting, PlaySoundWhenAgentDone, Settings, SettingsStore,
+    };
 
     #[gpui::test]
     fn test_agent_command_palette_visibility(cx: &mut TestAppContext) {
@@ -705,7 +707,7 @@ mod tests {
             default_profile: AgentProfileId::default(),
             profiles: Default::default(),
             notify_when_agent_waiting: NotifyWhenAgentWaiting::default(),
-            play_sound_when_agent_done: false,
+            play_sound_when_agent_done: PlaySoundWhenAgentDone::Never,
             single_file_review: false,
             model_parameters: vec![],
             enable_feedback: false,

crates/agent_ui/src/conversation_view.rs 🔗

@@ -2340,7 +2340,7 @@ impl ConversationView {
                     .is_some_and(|workspace| AgentPanel::is_visible(&workspace, cx))
             };
         #[cfg(feature = "audio")]
-        if settings.play_sound_when_agent_done && !_visible {
+        if settings.play_sound_when_agent_done.should_play(_visible) {
             Audio::play_sound(Sound::AgentDone, cx);
         }
     }

crates/migrator/src/migrations.rs 🔗

@@ -316,3 +316,9 @@ pub(crate) mod m_2026_03_23 {
 
     pub(crate) use keymap::KEYMAP_PATTERNS;
 }
+
+pub(crate) mod m_2026_03_30 {
+    mod settings;
+
+    pub(crate) use settings::make_play_sound_when_agent_done_an_enum;
+}

crates/migrator/src/migrations/m_2026_03_30/settings.rs 🔗

@@ -0,0 +1,29 @@
+use anyhow::Result;
+use serde_json::Value;
+
+use crate::migrations::migrate_settings;
+
+pub fn make_play_sound_when_agent_done_an_enum(value: &mut Value) -> Result<()> {
+    migrate_settings(value, &mut migrate_one)
+}
+
+fn migrate_one(obj: &mut serde_json::Map<String, Value>) -> Result<()> {
+    let Some(play_sound) = obj
+        .get_mut("agent")
+        .and_then(|agent| agent.as_object_mut())
+        .and_then(|agent| agent.get_mut("play_sound_when_agent_done"))
+    else {
+        return Ok(());
+    };
+
+    *play_sound = match play_sound {
+        Value::Bool(true) => Value::String("always".to_string()),
+        Value::Bool(false) => Value::String("never".to_string()),
+        Value::String(s) if s == "never" || s == "when_hidden" || s == "always" => return Ok(()),
+        _ => {
+            anyhow::bail!("Expected play_sound_when_agent_done to be a boolean or valid enum value")
+        }
+    };
+
+    Ok(())
+}

crates/migrator/src/migrator.rs 🔗

@@ -247,6 +247,7 @@ pub fn migrate_settings(text: &str) -> Result<Option<String>> {
             migrations::m_2026_03_16::SETTINGS_PATTERNS,
             &SETTINGS_QUERY_2026_03_16,
         ),
+        MigrationType::Json(migrations::m_2026_03_30::make_play_sound_when_agent_done_an_enum),
     ];
     run_migrations(text, migrations)
 }
@@ -2400,6 +2401,132 @@ mod tests {
         );
     }
 
+    #[test]
+    fn test_make_play_sound_when_agent_done_an_enum() {
+        assert_migrate_with_migrations(
+            &[MigrationType::Json(
+                migrations::m_2026_03_30::make_play_sound_when_agent_done_an_enum,
+            )],
+            &r#"{ }"#.unindent(),
+            None,
+        );
+
+        assert_migrate_with_migrations(
+            &[MigrationType::Json(
+                migrations::m_2026_03_30::make_play_sound_when_agent_done_an_enum,
+            )],
+            &r#"{
+                "agent": {
+                    "play_sound_when_agent_done": true
+                }
+            }"#
+            .unindent(),
+            Some(
+                &r#"{
+                    "agent": {
+                        "play_sound_when_agent_done": "always"
+                    }
+                }"#
+                .unindent(),
+            ),
+        );
+
+        assert_migrate_with_migrations(
+            &[MigrationType::Json(
+                migrations::m_2026_03_30::make_play_sound_when_agent_done_an_enum,
+            )],
+            &r#"{
+                "agent": {
+                    "play_sound_when_agent_done": false
+                }
+            }"#
+            .unindent(),
+            Some(
+                &r#"{
+                    "agent": {
+                        "play_sound_when_agent_done": "never"
+                    }
+                }"#
+                .unindent(),
+            ),
+        );
+
+        assert_migrate_with_migrations(
+            &[MigrationType::Json(
+                migrations::m_2026_03_30::make_play_sound_when_agent_done_an_enum,
+            )],
+            &r#"{
+                "agent": {
+                    "play_sound_when_agent_done": "when_hidden"
+                }
+            }"#
+            .unindent(),
+            None,
+        );
+
+        // Platform key: settings nested inside "macos" should be migrated
+        assert_migrate_with_migrations(
+            &[MigrationType::Json(
+                migrations::m_2026_03_30::make_play_sound_when_agent_done_an_enum,
+            )],
+            &r#"
+            {
+                "macos": {
+                    "agent": {
+                        "play_sound_when_agent_done": true
+                    }
+                }
+            }
+            "#
+            .unindent(),
+            Some(
+                &r#"
+                {
+                    "macos": {
+                        "agent": {
+                            "play_sound_when_agent_done": "always"
+                        }
+                    }
+                }
+                "#
+                .unindent(),
+            ),
+        );
+
+        // Profile: settings nested inside profiles should be migrated
+        assert_migrate_with_migrations(
+            &[MigrationType::Json(
+                migrations::m_2026_03_30::make_play_sound_when_agent_done_an_enum,
+            )],
+            &r#"
+            {
+                "profiles": {
+                    "work": {
+                        "agent": {
+                            "play_sound_when_agent_done": false
+                        }
+                    }
+                }
+            }
+            "#
+            .unindent(),
+            Some(
+                &r#"
+                {
+                    "profiles": {
+                        "work": {
+                            "agent": {
+                                "play_sound_when_agent_done": "never"
+                            }
+                        }
+                    }
+                }
+                "#
+                .unindent(),
+            ),
+        );
+    }
+
     #[test]
     fn test_remove_context_server_source() {
         assert_migrate_settings(

crates/settings_content/src/agent.rs 🔗

@@ -159,10 +159,10 @@ pub struct AgentSettingsContent {
     ///
     /// Default: "primary_screen"
     pub notify_when_agent_waiting: Option<NotifyWhenAgentWaiting>,
-    /// Whether to play a sound when the agent has either completed its response, or needs user input.
+    /// When to play a sound when the agent has either completed its response, or needs user input.
     ///
-    /// Default: false
-    pub play_sound_when_agent_done: Option<bool>,
+    /// Default: never
+    pub play_sound_when_agent_done: Option<PlaySoundWhenAgentDone>,
     /// Whether to display agent edits in single-file editors in addition to the review multibuffer pane.
     ///
     /// Default: true
@@ -347,6 +347,37 @@ pub enum NotifyWhenAgentWaiting {
     Never,
 }
 
+#[derive(
+    Copy,
+    Clone,
+    Default,
+    Debug,
+    Serialize,
+    Deserialize,
+    JsonSchema,
+    MergeFrom,
+    PartialEq,
+    strum::VariantArray,
+    strum::VariantNames,
+)]
+#[serde(rename_all = "snake_case")]
+pub enum PlaySoundWhenAgentDone {
+    #[default]
+    Never,
+    WhenHidden,
+    Always,
+}
+
+impl PlaySoundWhenAgentDone {
+    pub fn should_play(&self, visible: bool) -> bool {
+        match self {
+            PlaySoundWhenAgentDone::Never => false,
+            PlaySoundWhenAgentDone::WhenHidden => !visible,
+            PlaySoundWhenAgentDone::Always => true,
+        }
+    }
+}
+
 #[with_fallible_options]
 #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
 pub struct LanguageModelSelection {

crates/settings_ui/src/page_data.rs 🔗

@@ -7278,7 +7278,7 @@ fn ai_page(cx: &App) -> SettingsPage {
             }),
             SettingsPageItem::SettingItem(SettingItem {
                 title: "Play Sound When Agent Done",
-                description: "Whether to play a sound when the agent has either completed its response, or needs user input.",
+                description: "When to play a sound when the agent has either completed its response, or needs user input.",
                 field: Box::new(SettingField {
                     json_path: Some("agent.play_sound_when_agent_done"),
                     pick: |settings_content| {

crates/settings_ui/src/settings_ui.rs 🔗

@@ -523,6 +523,7 @@ fn init_renderers(cx: &mut App) {
         .add_basic_renderer::<settings::VimInsertModeCursorShape>(render_dropdown)
         .add_basic_renderer::<settings::SteppingGranularity>(render_dropdown)
         .add_basic_renderer::<settings::NotifyWhenAgentWaiting>(render_dropdown)
+        .add_basic_renderer::<settings::PlaySoundWhenAgentDone>(render_dropdown)
         .add_basic_renderer::<settings::NewThreadLocation>(render_dropdown)
         .add_basic_renderer::<settings::ThinkingBlockDisplay>(render_dropdown)
         .add_basic_renderer::<settings::ImageFileSizeUnit>(render_dropdown)

crates/zed/src/visual_test_runner.rs 🔗

@@ -109,7 +109,7 @@ use {
     image::RgbaImage,
     project::{AgentId, Project},
     project_panel::ProjectPanel,
-    settings::{NotifyWhenAgentWaiting, Settings as _},
+    settings::{NotifyWhenAgentWaiting, PlaySoundWhenAgentDone, Settings as _},
     settings_ui::SettingsWindow,
     std::{
         any::Any,
@@ -231,7 +231,7 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()>
         agent_settings::AgentSettings::override_global(
             agent_settings::AgentSettings {
                 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
-                play_sound_when_agent_done: false,
+                play_sound_when_agent_done: PlaySoundWhenAgentDone::Never,
                 ..agent_settings::AgentSettings::get_global(cx).clone()
             },
             cx,

docs/src/ai/agent-settings.md 🔗

@@ -292,13 +292,16 @@ The default value is `false`.
 
 ### Sound Notification
 
-Control whether to hear a notification sound when the agent is done generating changes or needs your input.
-The default value is `false`.
+Control whether to hear a notification sound when the agent is done generating changes or needs your input. The default value is `never`.
+
+- `"never"` (default) — Never play the sound.
+- `"when_hidden"` — Only play the sound when the agent panel is not visible.
+- `"always"` — Always play the sound on completion.
 
 ```json [settings]
 {
   "agent": {
-    "play_sound_when_agent_done": true
+    "play_sound_when_agent_done": "never"
   }
 }
 ```