agent_settings: Add a way to set the layout settings en-masse (#52777)

Mikayla Maki created

Use `AgentSettings::get_layout(cx)` to retrieve the current, exact value of the user's layout settings, and `AgentSettings::set_layout(WindowLayout::agent())` or `AgentSettings::set_layout(cached_user_settings)` to write to them.

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Closes #ISSUE

Release Notes:

- N/A

Change summary

Cargo.lock                                  |   1 
crates/agent_settings/Cargo.toml            |   1 
crates/agent_settings/src/agent_settings.rs | 367 ++++++++++++++++++++++
crates/settings/src/settings_store.rs       |   4 
4 files changed, 370 insertions(+), 3 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -312,6 +312,7 @@ dependencies = [
  "gpui",
  "language_model",
  "log",
+ "paths",
  "project",
  "regex",
  "schemars",

crates/agent_settings/Cargo.toml 🔗

@@ -30,6 +30,7 @@ util.workspace = true
 [dev-dependencies]
 fs.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
+paths.workspace = true
 
 serde_json_lenient.workspace = true
 serde_json.workspace = true

crates/agent_settings/src/agent_settings.rs 🔗

@@ -5,15 +5,17 @@ use std::sync::{Arc, LazyLock};
 
 use agent_client_protocol::ModelId;
 use collections::{HashSet, IndexMap};
+use fs::Fs;
 use gpui::{App, Pixels, px};
 use language_model::LanguageModel;
 use project::DisableAiSettings;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::{
-    DefaultAgentView, DockPosition, LanguageModelParameters, LanguageModelSelection,
-    NewThreadLocation, NotifyWhenAgentWaiting, RegisterSetting, Settings, SidebarDockPosition,
-    SidebarSide, ThinkingBlockDisplay, ToolPermissionMode,
+    DefaultAgentView, DockPosition, DockSide, LanguageModelParameters, LanguageModelSelection,
+    NewThreadLocation, NotifyWhenAgentWaiting, RegisterSetting, Settings, SettingsContent,
+    SettingsStore, SidebarDockPosition, SidebarSide, ThinkingBlockDisplay, ToolPermissionMode,
+    update_settings_file,
 };
 
 pub use crate::agent_profile::*;
@@ -22,6 +24,104 @@ pub const SUMMARIZE_THREAD_PROMPT: &str = include_str!("prompts/summarize_thread
 pub const SUMMARIZE_THREAD_DETAILED_PROMPT: &str =
     include_str!("prompts/summarize_thread_detailed_prompt.txt");
 
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct PanelLayout {
+    pub(crate) agent_dock: Option<DockPosition>,
+    pub(crate) project_panel_dock: Option<DockSide>,
+    pub(crate) outline_panel_dock: Option<DockSide>,
+    pub(crate) collaboration_panel_dock: Option<DockPosition>,
+    pub(crate) git_panel_dock: Option<DockPosition>,
+    pub(crate) notification_panel_button: Option<bool>,
+}
+
+impl PanelLayout {
+    const AGENT: Self = Self {
+        agent_dock: Some(DockPosition::Left),
+        project_panel_dock: Some(DockSide::Right),
+        outline_panel_dock: Some(DockSide::Right),
+        collaboration_panel_dock: Some(DockPosition::Right),
+        git_panel_dock: Some(DockPosition::Right),
+        notification_panel_button: Some(false),
+    };
+
+    const EDITOR: Self = Self {
+        agent_dock: Some(DockPosition::Right),
+        project_panel_dock: Some(DockSide::Left),
+        outline_panel_dock: Some(DockSide::Left),
+        collaboration_panel_dock: Some(DockPosition::Left),
+        git_panel_dock: Some(DockPosition::Left),
+        notification_panel_button: Some(true),
+    };
+
+    pub fn is_agent_layout(&self) -> bool {
+        *self == Self::AGENT
+    }
+
+    pub fn is_editor_layout(&self) -> bool {
+        *self == Self::EDITOR
+    }
+
+    fn read_from(content: &SettingsContent) -> Self {
+        Self {
+            agent_dock: content.agent.as_ref().and_then(|a| a.dock),
+            project_panel_dock: content.project_panel.as_ref().and_then(|p| p.dock),
+            outline_panel_dock: content.outline_panel.as_ref().and_then(|p| p.dock),
+            collaboration_panel_dock: content.collaboration_panel.as_ref().and_then(|p| p.dock),
+            git_panel_dock: content.git_panel.as_ref().and_then(|p| p.dock),
+            notification_panel_button: content.notification_panel.as_ref().and_then(|p| p.button),
+        }
+    }
+
+    fn write_to(&self, settings: &mut SettingsContent) {
+        settings.agent.get_or_insert_default().dock = self.agent_dock;
+        settings.project_panel.get_or_insert_default().dock = self.project_panel_dock;
+        settings.outline_panel.get_or_insert_default().dock = self.outline_panel_dock;
+        settings.collaboration_panel.get_or_insert_default().dock = self.collaboration_panel_dock;
+        settings.git_panel.get_or_insert_default().dock = self.git_panel_dock;
+        settings.notification_panel.get_or_insert_default().button = self.notification_panel_button;
+    }
+
+    fn write_diff_to(&self, current_merged: &PanelLayout, settings: &mut SettingsContent) {
+        if self.agent_dock != current_merged.agent_dock {
+            settings.agent.get_or_insert_default().dock = self.agent_dock;
+        }
+        if self.project_panel_dock != current_merged.project_panel_dock {
+            settings.project_panel.get_or_insert_default().dock = self.project_panel_dock;
+        }
+        if self.outline_panel_dock != current_merged.outline_panel_dock {
+            settings.outline_panel.get_or_insert_default().dock = self.outline_panel_dock;
+        }
+        if self.collaboration_panel_dock != current_merged.collaboration_panel_dock {
+            settings.collaboration_panel.get_or_insert_default().dock =
+                self.collaboration_panel_dock;
+        }
+        if self.git_panel_dock != current_merged.git_panel_dock {
+            settings.git_panel.get_or_insert_default().dock = self.git_panel_dock;
+        }
+        if self.notification_panel_button != current_merged.notification_panel_button {
+            settings.notification_panel.get_or_insert_default().button =
+                self.notification_panel_button;
+        }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum WindowLayout {
+    Editor(Option<PanelLayout>),
+    Agent(Option<PanelLayout>),
+    Custom(PanelLayout),
+}
+
+impl WindowLayout {
+    pub fn agent() -> Self {
+        Self::Agent(None)
+    }
+
+    pub fn editor() -> Self {
+        Self::Editor(None)
+    }
+}
+
 #[derive(Clone, Debug, RegisterSetting)]
 pub struct AgentSettings {
     pub enabled: bool,
@@ -98,6 +198,50 @@ impl AgentSettings {
             .map(|sel| ModelId::new(format!("{}/{}", sel.provider.0, sel.model)))
             .collect()
     }
+
+    pub fn get_layout(cx: &App) -> WindowLayout {
+        let store = cx.global::<SettingsStore>();
+        let merged = store.merged_settings();
+        let user_layout = store
+            .raw_user_settings()
+            .map(|u| PanelLayout::read_from(u.content.as_ref()))
+            .unwrap_or_default();
+        let merged_layout = PanelLayout::read_from(merged);
+
+        if merged_layout.is_agent_layout() {
+            return WindowLayout::Agent(Some(user_layout));
+        }
+
+        if merged_layout.is_editor_layout() {
+            return WindowLayout::Editor(Some(user_layout));
+        }
+
+        WindowLayout::Custom(user_layout)
+    }
+
+    pub fn set_layout(layout: WindowLayout, fs: Arc<dyn Fs>, cx: &App) {
+        let merged = PanelLayout::read_from(cx.global::<SettingsStore>().merged_settings());
+
+        match layout {
+            WindowLayout::Agent(None) => {
+                update_settings_file(fs, cx, move |settings, _cx| {
+                    PanelLayout::AGENT.write_diff_to(&merged, settings);
+                });
+            }
+            WindowLayout::Editor(None) => {
+                update_settings_file(fs, cx, move |settings, _cx| {
+                    PanelLayout::EDITOR.write_diff_to(&merged, settings);
+                });
+            }
+            WindowLayout::Agent(Some(saved))
+            | WindowLayout::Editor(Some(saved))
+            | WindowLayout::Custom(saved) => {
+                update_settings_file(fs, cx, move |settings, _cx| {
+                    saved.write_to(settings);
+                });
+            }
+        }
+    }
 }
 
 #[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, JsonSchema)]
@@ -555,6 +699,7 @@ fn compile_regex_rules(
 #[cfg(test)]
 mod tests {
     use super::*;
+    use gpui::{TestAppContext, UpdateGlobal};
     use serde_json::json;
     use settings::ToolPermissionMode;
     use settings::ToolPermissionsContent;
@@ -1031,4 +1176,220 @@ mod tests {
         let permissions = compile_tool_permissions(Some(content));
         assert_eq!(permissions.default, ToolPermissionMode::Deny);
     }
+
+    #[gpui::test]
+    fn test_get_layout(cx: &mut gpui::App) {
+        let store = SettingsStore::test(cx);
+        cx.set_global(store);
+        project::DisableAiSettings::register(cx);
+        AgentSettings::register(cx);
+
+        // The test default settings match the editor layout.
+        let layout = AgentSettings::get_layout(cx);
+        assert!(matches!(layout, WindowLayout::Editor(_)));
+
+        // Switch defaults to the agent layout (simulating the AgentV2 flag).
+        SettingsStore::update_global(cx, |store, cx| {
+            store.update_default_settings(cx, |defaults| {
+                defaults.agent.get_or_insert_default().dock = Some(DockPosition::Left);
+                defaults.project_panel.get_or_insert_default().dock = Some(DockSide::Right);
+                defaults.outline_panel.get_or_insert_default().dock = Some(DockSide::Right);
+                defaults.collaboration_panel.get_or_insert_default().dock =
+                    Some(DockPosition::Right);
+                defaults.git_panel.get_or_insert_default().dock = Some(DockPosition::Right);
+                defaults.notification_panel.get_or_insert_default().button = Some(false);
+            });
+        });
+
+        // Should be Agent with an empty user layout (user hasn't customized).
+        let layout = AgentSettings::get_layout(cx);
+        let WindowLayout::Agent(Some(user_layout)) = layout else {
+            panic!("expected Agent(Some), got {:?}", layout);
+        };
+        assert_eq!(user_layout, PanelLayout::default());
+
+        // User explicitly sets agent dock to left (matching the default).
+        // The merged result is still agent, but the user layout captures
+        // only what the user wrote.
+        SettingsStore::update_global(cx, |store, cx| {
+            store
+                .set_user_settings(r#"{ "agent": { "dock": "left" } }"#, cx)
+                .unwrap();
+        });
+
+        let layout = AgentSettings::get_layout(cx);
+        let WindowLayout::Agent(Some(user_layout)) = layout else {
+            panic!("expected Agent(Some), got {:?}", layout);
+        };
+        assert_eq!(user_layout.agent_dock, Some(DockPosition::Left));
+        assert_eq!(user_layout.project_panel_dock, None);
+        assert_eq!(user_layout.outline_panel_dock, None);
+        assert_eq!(user_layout.collaboration_panel_dock, None);
+        assert_eq!(user_layout.git_panel_dock, None);
+        assert_eq!(user_layout.notification_panel_button, None);
+
+        // User sets a combination that doesn't match either preset:
+        // agent on the left but project panel also on the left.
+        SettingsStore::update_global(cx, |store, cx| {
+            store
+                .set_user_settings(
+                    r#"{
+                        "agent": { "dock": "left" },
+                        "project_panel": { "dock": "left" }
+                    }"#,
+                    cx,
+                )
+                .unwrap();
+        });
+
+        let layout = AgentSettings::get_layout(cx);
+        let WindowLayout::Custom(user_layout) = layout else {
+            panic!("expected Custom, got {:?}", layout);
+        };
+        assert_eq!(user_layout.agent_dock, Some(DockPosition::Left));
+        assert_eq!(user_layout.project_panel_dock, Some(DockSide::Left));
+    }
+
+    #[gpui::test]
+    fn test_set_layout_round_trip(cx: &mut gpui::App) {
+        let store = SettingsStore::test(cx);
+        cx.set_global(store);
+        project::DisableAiSettings::register(cx);
+        AgentSettings::register(cx);
+
+        // User has a custom layout: agent on the right with project panel
+        // also on the right. This doesn't match either preset.
+        SettingsStore::update_global(cx, |store, cx| {
+            store
+                .set_user_settings(
+                    r#"{
+                        "agent": { "dock": "right" },
+                        "project_panel": { "dock": "right" }
+                    }"#,
+                    cx,
+                )
+                .unwrap();
+        });
+
+        let original = AgentSettings::get_layout(cx);
+        let WindowLayout::Custom(ref original_user_layout) = original else {
+            panic!("expected Custom, got {:?}", original);
+        };
+        assert_eq!(original_user_layout.agent_dock, Some(DockPosition::Right));
+        assert_eq!(
+            original_user_layout.project_panel_dock,
+            Some(DockSide::Right)
+        );
+        assert_eq!(original_user_layout.outline_panel_dock, None);
+
+        // Switch to the agent layout. This overwrites the user settings.
+        SettingsStore::update_global(cx, |store, cx| {
+            store.update_user_settings(cx, |settings| {
+                PanelLayout::AGENT.write_to(settings);
+            });
+        });
+
+        let layout = AgentSettings::get_layout(cx);
+        assert!(matches!(layout, WindowLayout::Agent(_)));
+
+        // Restore the original custom layout.
+        SettingsStore::update_global(cx, |store, cx| {
+            store.update_user_settings(cx, |settings| {
+                original_user_layout.write_to(settings);
+            });
+        });
+
+        // Should be back to the same custom layout.
+        let restored = AgentSettings::get_layout(cx);
+        let WindowLayout::Custom(restored_user_layout) = restored else {
+            panic!("expected Custom, got {:?}", restored);
+        };
+        assert_eq!(restored_user_layout.agent_dock, Some(DockPosition::Right));
+        assert_eq!(
+            restored_user_layout.project_panel_dock,
+            Some(DockSide::Right)
+        );
+        assert_eq!(restored_user_layout.outline_panel_dock, None);
+    }
+
+    #[gpui::test]
+    async fn test_set_layout_minimal_diff(cx: &mut TestAppContext) {
+        let fs = fs::FakeFs::new(cx.background_executor.clone());
+        fs.save(
+            paths::settings_file().as_path(),
+            &serde_json::json!({
+                "agent": { "dock": "left" },
+                "project_panel": { "dock": "left" }
+            })
+            .to_string()
+            .into(),
+            Default::default(),
+        )
+        .await
+        .unwrap();
+
+        cx.update(|cx| {
+            let store = SettingsStore::test(cx);
+            cx.set_global(store);
+            project::DisableAiSettings::register(cx);
+            AgentSettings::register(cx);
+
+            // Apply the agent V2 defaults.
+            SettingsStore::update_global(cx, |store, cx| {
+                store.update_default_settings(cx, |defaults| {
+                    defaults.agent.get_or_insert_default().dock = Some(DockPosition::Left);
+                    defaults.project_panel.get_or_insert_default().dock = Some(DockSide::Right);
+                    defaults.outline_panel.get_or_insert_default().dock = Some(DockSide::Right);
+                    defaults.collaboration_panel.get_or_insert_default().dock =
+                        Some(DockPosition::Right);
+                    defaults.git_panel.get_or_insert_default().dock = Some(DockPosition::Right);
+                    defaults.notification_panel.get_or_insert_default().button = Some(false);
+                });
+            });
+
+            // User has agent=left (matches preset) and project_panel=left (does not)
+            SettingsStore::update_global(cx, |store, cx| {
+                store
+                    .set_user_settings(
+                        r#"{
+                            "agent": { "dock": "left" },
+                            "project_panel": { "dock": "left" }
+                        }"#,
+                        cx,
+                    )
+                    .unwrap();
+            });
+
+            let layout = AgentSettings::get_layout(cx);
+            assert!(matches!(layout, WindowLayout::Custom(_)));
+
+            AgentSettings::set_layout(WindowLayout::agent(), fs.clone(), cx);
+        });
+
+        cx.run_until_parked();
+
+        let written = fs.load(paths::settings_file().as_path()).await.unwrap();
+        cx.update(|cx| {
+            SettingsStore::update_global(cx, |store, cx| {
+                store.set_user_settings(&written, cx).unwrap();
+            });
+
+            // The user settings should still have agent=left (preserved)
+            // and now project_panel=right (changed to match preset).
+            let store = cx.global::<SettingsStore>();
+            let user_layout = store
+                .raw_user_settings()
+                .map(|u| PanelLayout::read_from(u.content.as_ref()))
+                .unwrap_or_default();
+
+            assert_eq!(user_layout.agent_dock, Some(DockPosition::Left));
+            assert_eq!(user_layout.project_panel_dock, Some(DockSide::Right));
+            // Other fields weren't in user settings and didn't need changing.
+            assert_eq!(user_layout.outline_panel_dock, None);
+
+            // And the merged result should now match agent.
+            let layout = AgentSettings::get_layout(cx);
+            assert!(matches!(layout, WindowLayout::Agent(_)));
+        });
+    }
 }

crates/settings/src/settings_store.rs 🔗

@@ -370,6 +370,10 @@ impl SettingsStore {
         setting_value.set_global_value(value);
     }
 
+    pub fn merged_settings(&self) -> &SettingsContent {
+        &self.merged_settings
+    }
+
     /// Get the value of a setting.
     ///
     /// Panics if the given setting type has not been registered, or if there is no