diff --git a/assets/settings/default.json b/assets/settings/default.json index b84dc97485..4c7781c958 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1162,15 +1162,15 @@ // // Default: true "flexible": true, - // Where to position the sidebar holding the agent panel and the list of - // agent threads. Can be 'left' or 'right'. - "sidebar_side": "left", - // Default width of the Threads Sidebar. - "threads_sidebar_default_width": 300, - // Whether opening a folder in an existing window automatically opens the - // Threads Sidebar. Applies when `default_open_behavior` or - // `cli_default_open_behavior` is set to "existing_window". - "threads_sidebar_auto_open": true, + // Settings for the sidebar holding the agent panel and the list of agent threads. + "threads_sidebar": { + // Where to position the Threads Sidebar. Can be 'left' or 'right'. + "position": "left", + // Default width of the Threads Sidebar. + "default_width": 300, + // Automatically open the Threads Sidebar when opening a folder in an existing window. + "auto_open": true, + }, // Default fixed width when the agent panel is docked to the left or right and // `flexible` is false. "default_width": 640, diff --git a/crates/agent/src/tool_permissions.rs b/crates/agent/src/tool_permissions.rs index cd8a02b202..bbb680d39e 100644 --- a/crates/agent/src/tool_permissions.rs +++ b/crates/agent/src/tool_permissions.rs @@ -608,9 +608,11 @@ mod tests { sandbox_permissions: Default::default(), show_turn_stats: false, show_merge_conflict_indicator: true, - sidebar_side: Default::default(), - threads_sidebar_default_width: px(300.), - threads_sidebar_auto_open: true, + threads_sidebar: agent_settings::ThreadsSidebarSettings { + auto_open: true, + default_width: px(300.), + position: settings::SidebarDockPosition::Left, + }, thinking_display: Default::default(), } } diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index fd64625370..193cc5a8c0 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -213,9 +213,7 @@ pub struct AgentSettings { pub button: bool, pub dock: DockPosition, pub flexible: bool, - pub sidebar_side: SidebarDockPosition, - pub threads_sidebar_default_width: Pixels, - pub threads_sidebar_auto_open: bool, + pub threads_sidebar: ThreadsSidebarSettings, pub default_width: Pixels, pub default_height: Pixels, pub max_content_width: Option, @@ -253,6 +251,13 @@ pub struct AgentSettings { pub sandbox_permissions: SandboxPermissions, } +#[derive(Clone, Copy, Debug)] +pub struct ThreadsSidebarSettings { + pub auto_open: bool, + pub position: SidebarDockPosition, + pub default_width: Pixels, +} + impl AgentSettings { pub fn enabled(&self, cx: &App) -> bool { self.enabled && !DisableAiSettings::get_global(cx).disable_ai @@ -277,7 +282,7 @@ impl AgentSettings { } pub fn sidebar_side(&self) -> SidebarSide { - match self.sidebar_side { + match self.threads_sidebar.position { SidebarDockPosition::Left => SidebarSide::Left, SidebarDockPosition::Right => SidebarSide::Right, } @@ -763,19 +768,22 @@ pub fn normalize_path(raw: &str) -> String { impl Settings for AgentSettings { fn from_settings(content: &settings::SettingsContent) -> Self { let agent = content.agent.clone().unwrap(); + let threads_sidebar = agent.threads_sidebar.unwrap(); Self { enabled: agent.enabled.unwrap(), button: agent.button.unwrap(), dock: agent.dock.unwrap(), - sidebar_side: agent.sidebar_side.unwrap(), - // Clamped once here so that every reader gets a width the sidebar can - // actually hold, rather than each call site having to remember to. - threads_sidebar_default_width: agent - .threads_sidebar_default_width - .unwrap() - .into_gpui() - .clamp(THREADS_LIST_MIN_WIDTH, THREADS_LIST_MAX_WIDTH), - threads_sidebar_auto_open: agent.threads_sidebar_auto_open.unwrap(), + threads_sidebar: ThreadsSidebarSettings { + auto_open: threads_sidebar.auto_open.unwrap(), + position: threads_sidebar.position.unwrap(), + // Clamped once here so that every reader gets a width the sidebar can + // actually hold, rather than each call site having to remember to. + default_width: threads_sidebar + .default_width + .unwrap() + .into_gpui() + .clamp(THREADS_LIST_MIN_WIDTH, THREADS_LIST_MAX_WIDTH), + }, default_width: agent.default_width.unwrap().into_gpui(), default_height: agent.default_height.unwrap().into_gpui(), max_content_width: if agent.limit_content_width.unwrap() { @@ -1009,8 +1017,7 @@ mod tests { use super::*; use gpui::{TestAppContext, UpdateGlobal, px}; use serde_json::json; - use settings::ToolPermissionMode; - use settings::ToolPermissionsContent; + use settings::{ToolPermissionMode, ToolPermissionsContent}; use std::path::PathBuf; #[test] @@ -1108,54 +1115,203 @@ mod tests { } #[gpui::test] - fn test_threads_sidebar_default_width(cx: &mut gpui::App) { + fn test_threads_sidebar_settings(cx: &mut gpui::App) { let store = SettingsStore::test(cx); cx.set_global(store); project::DisableAiSettings::register(cx); AgentSettings::register(cx); - assert_eq!( - AgentSettings::get_global(cx).threads_sidebar_default_width, - px(300.), - "default.json supplies the Threads Sidebar width" - ); - - SettingsStore::update_global(cx, |store, cx| { - store - .set_user_settings( - r#"{ "agent": { "threads_sidebar_default_width": 360 } }"#, - cx, - ) - .expect("user settings load"); - }); - - let settings = AgentSettings::get_global(cx); - assert_eq!(settings.threads_sidebar_default_width, px(360.)); - assert_eq!( - settings.default_width, - px(640.), - "setting the Threads Sidebar width leaves the agent panel width unchanged" - ); - assert_eq!( - settings.sidebar_side, - SidebarDockPosition::Left, - "setting the Threads Sidebar width leaves its position unchanged" - ); - - for content in [ - r#"{ "agent": { "threads_sidebar_default_width": null } }"#, - r#"{ "agent": {} }"#, + for (content, expected_position, expected_width) in [ + (r#"{}"#, SidebarDockPosition::Left, px(300.)), + ( + r#"{ "agent": { "threads_sidebar": { "position": "right", "default_width": 360 } } }"#, + SidebarDockPosition::Right, + px(360.), + ), + ( + r#"{ "agent": { "threads_sidebar": { "position": "right" } } }"#, + SidebarDockPosition::Right, + px(300.), + ), + ( + r#"{ "agent": { "threads_sidebar": { "default_width": 360 } } }"#, + SidebarDockPosition::Left, + px(360.), + ), + ( + r#"{ "agent": { "threads_sidebar": { "position": null, "default_width": null } } }"#, + SidebarDockPosition::Left, + px(300.), + ), + ( + r#"{ "agent": { "threads_sidebar": null } }"#, + SidebarDockPosition::Left, + px(300.), + ), + ( + r#"{ "agent": { "threads_sidebar": { "default_width": 5 } } }"#, + SidebarDockPosition::Left, + THREADS_LIST_MIN_WIDTH, + ), + ( + r#"{ "agent": { "threads_sidebar": { "default_width": 5000 } } }"#, + SidebarDockPosition::Left, + THREADS_LIST_MAX_WIDTH, + ), ] { SettingsStore::update_global(cx, |store, cx| { store .set_user_settings(content, cx) .expect("user settings load"); }); + let settings = AgentSettings::get_global(cx); assert_eq!( - AgentSettings::get_global(cx).threads_sidebar_default_width, - px(300.), - "an unset width falls back to the default" + settings.threads_sidebar.position, expected_position, + "{content}" ); + assert_eq!( + settings.threads_sidebar.default_width, expected_width, + "{content}" + ); + assert_eq!( + settings.sidebar_side(), + match expected_position { + SidebarDockPosition::Left => SidebarSide::Left, + SidebarDockPosition::Right => SidebarSide::Right, + }, + "{content}" + ); + assert_eq!(settings.default_width, px(640.), "{content}"); + assert!(settings.threads_sidebar.auto_open, "{content}"); + } + + for (value, expected) in [("false", false), ("true", true), ("null", true)] { + SettingsStore::update_global(cx, |store, cx| { + store + .set_user_settings( + &format!(r#"{{"agent":{{"threads_sidebar":{{"auto_open":{value}}}}}}}"#), + cx, + ) + .expect("user settings load"); + }); + assert_eq!( + AgentSettings::get_global(cx).threads_sidebar.auto_open, + expected + ); + } + } + + #[gpui::test] + fn test_threads_sidebar_settings_edit_and_reset_clears_legacy_keys(cx: &mut gpui::App) { + let store = SettingsStore::test(cx); + cx.set_global(store); + project::DisableAiSettings::register(cx); + AgentSettings::register(cx); + + #[derive(Clone, Copy)] + enum Field { + Position, + DefaultWidth, + AutoOpen, + } + + let cases = [ + (Field::Position, "sidebar_side"), + (Field::DefaultWidth, "threads_sidebar_default_width"), + (Field::AutoOpen, "threads_sidebar_auto_open"), + ]; + + let content = r#" + { + // This comment and the unrelated setting should survive edits. + "agent": { + "sidebar_side": "right", + "threads_sidebar_default_width": 420, + "threads_sidebar_auto_open": true, + "threads_sidebar": { + "position": "left", + "default_width": 360, + "auto_open": false + } + }, + "unrelated": { "keep": true } + } + "#; + + for (field, legacy_key) in cases { + let mut rewrite = |old_text: String, set_value: bool| { + SettingsStore::update_global(cx, |store, _| { + store + .new_text_for_update(old_text, |settings| { + let agent = settings.agent.get_or_insert_default(); + match field { + Field::Position => agent.set_threads_sidebar_position( + set_value.then_some(SidebarDockPosition::Right), + ), + Field::DefaultWidth => agent.set_threads_sidebar_default_width( + set_value.then_some(500.0.into()), + ), + Field::AutoOpen => { + agent.set_threads_sidebar_auto_open(set_value.then_some(true)) + } + } + }) + .expect("settings update should succeed") + }) + }; + let assert_legacy_edit = |text: &str| { + let value: serde_json_lenient::Value = + serde_json_lenient::from_str(text).expect("rewritten settings are valid"); + let agent = value + .get("agent") + .and_then(serde_json_lenient::Value::as_object) + .expect("agent settings should remain an object"); + assert!( + !agent.contains_key(legacy_key), + "{legacy_key} was not removed" + ); + for sibling in [ + "sidebar_side", + "threads_sidebar_default_width", + "threads_sidebar_auto_open", + ] { + if sibling != legacy_key { + assert!(agent.contains_key(sibling), "{sibling} was not preserved"); + } + } + assert_eq!( + value + .get("unrelated") + .and_then(serde_json_lenient::Value::as_object) + .and_then(|object| object.get("keep")) + .and_then(serde_json_lenient::Value::as_bool), + Some(true) + ); + assert!(text.contains("This comment and the unrelated setting")); + }; + + let reset_text = rewrite(content.to_string(), false); + assert_legacy_edit(&reset_text); + let set_text = rewrite(content.to_string(), true); + assert_legacy_edit(&set_text); + let reset_after_set_text = rewrite(set_text, false); + assert_legacy_edit(&reset_after_set_text); + + let expected_width = match field { + Field::DefaultWidth => px(300.), + Field::Position | Field::AutoOpen => px(360.), + }; + let expected_auto_open = matches!(field, Field::AutoOpen); + for rewritten in [reset_text, reset_after_set_text] { + SettingsStore::update_global(cx, |store, cx| { + let result = store.set_user_settings(&rewritten, cx); + assert!(result.parse_error().is_none(), "settings should parse"); + }); + let settings = AgentSettings::get_global(cx); + assert_eq!(settings.threads_sidebar.position, SidebarDockPosition::Left); + assert_eq!(settings.threads_sidebar.default_width, expected_width); + assert_eq!(settings.threads_sidebar.auto_open, expected_auto_open); + } } } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index f25c16a4d3..c9e823bd26 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -1010,9 +1010,11 @@ mod tests { sandbox_permissions: Default::default(), show_turn_stats: false, show_merge_conflict_indicator: true, - sidebar_side: Default::default(), - threads_sidebar_default_width: px(300.), - threads_sidebar_auto_open: true, + threads_sidebar: agent_settings::ThreadsSidebarSettings { + auto_open: true, + default_width: px(300.), + position: settings::SidebarDockPosition::Left, + }, thinking_display: Default::default(), }; diff --git a/crates/migrator/src/migrations.rs b/crates/migrator/src/migrations.rs index 2088dfc4ec..516b9dfc45 100644 --- a/crates/migrator/src/migrations.rs +++ b/crates/migrator/src/migrations.rs @@ -376,3 +376,9 @@ pub(crate) mod m_2026_08_30 { pub(crate) use settings::nest_markdown_preview_settings; } + +pub(crate) mod m_2026_09_16 { + mod settings; + + pub(crate) use settings::nest_agent_threads_sidebar_settings; +} diff --git a/crates/migrator/src/migrations/m_2026_09_16/settings.rs b/crates/migrator/src/migrations/m_2026_09_16/settings.rs new file mode 100644 index 0000000000..fa008f1a8d --- /dev/null +++ b/crates/migrator/src/migrations/m_2026_09_16/settings.rs @@ -0,0 +1,170 @@ +use anyhow::Result; +use serde_json::Value; + +use crate::migrations::migrate_settings; + +const THREADS_SIDEBAR_KEY: &str = "threads_sidebar"; +const KEY_MAPPINGS: &[(&str, &str)] = &[ + ("sidebar_side", "position"), + ("threads_sidebar_default_width", "default_width"), + ("threads_sidebar_auto_open", "auto_open"), +]; + +pub fn nest_agent_threads_sidebar_settings(value: &mut Value) -> Result<()> { + migrate_settings(value, &mut migrate_one) +} + +fn migrate_one(object: &mut serde_json::Map) -> Result<()> { + let Some(agent) = object.get_mut("agent").and_then(Value::as_object_mut) else { + return Ok(()); + }; + let has_legacy_settings = KEY_MAPPINGS + .iter() + .any(|(old_key, _)| agent.contains_key(*old_key)); + if !has_legacy_settings { + return Ok(()); + } + + let mut threads_sidebar = match agent.remove(THREADS_SIDEBAR_KEY) { + None | Some(Value::Null) => serde_json::Map::new(), + Some(Value::Object(settings)) => settings, + Some(value) => { + agent.insert(THREADS_SIDEBAR_KEY.into(), value); + return Ok(()); + } + }; + + for (old_key, new_key) in KEY_MAPPINGS { + if let Some(value) = agent.remove(*old_key) { + threads_sidebar.entry(*new_key).or_insert(value); + } + } + agent.insert(THREADS_SIDEBAR_KEY.into(), Value::Object(threads_sidebar)); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn migrate(value: &mut Value) { + nest_agent_threads_sidebar_settings(value).expect("migration should succeed"); + } + + #[test] + fn nest_agent_threads_sidebar_settings_moves_keys_and_preserves_unrelated_fields() { + let mut value = json!({ + "agent": { + "sidebar_side": "right", + "threads_sidebar_default_width": 420, + "threads_sidebar_auto_open": false, + "button": false + } + }); + migrate(&mut value); + assert_eq!( + value, + json!({ + "agent": { + "threads_sidebar": { "position": "right", "default_width": 420, "auto_open": false }, + "button": false + } + }) + ); + let migrated = value.clone(); + migrate(&mut value); + assert_eq!(value, migrated); + } + + #[test] + fn nest_agent_threads_sidebar_settings_preserves_nested_values_and_fills_missing_fields() { + let mut value = json!({ + "agent": { + "sidebar_side": "left", + "threads_sidebar_default_width": 360, + "threads_sidebar_auto_open": true, + "threads_sidebar": { "position": null, "auto_open": false } + } + }); + migrate(&mut value); + assert_eq!( + value, + json!({ + "agent": { + "threads_sidebar": { "position": null, "default_width": 360, "auto_open": false } + } + }) + ); + } + + #[test] + fn nest_agent_threads_sidebar_settings_handles_null_destinations_only_when_needed() { + let mut with_legacy = json!({ + "agent": { + "sidebar_side": "right", + "threads_sidebar": null + } + }); + migrate(&mut with_legacy); + assert_eq!( + with_legacy, + json!({ "agent": { "threads_sidebar": { "position": "right" } } }) + ); + } + + #[test] + fn nest_agent_threads_sidebar_settings_leaves_unrelated_and_malformed_values_untouched() { + for mut value in [ + json!({ "agent": { "threads_sidebar": null } }), + json!({ "agent": false }), + json!({ + "agent": { + "sidebar_side": "left", + "threads_sidebar_default_width": 360, + "threads_sidebar": false + } + }), + ] { + let original = value.clone(); + migrate(&mut value); + assert_eq!(value, original); + } + } + + #[test] + fn nest_agent_threads_sidebar_settings_migrates_all_supported_scopes() { + let legacy_agent = json!({ + "sidebar_side": "right", + "threads_sidebar_default_width": 420, + "threads_sidebar_auto_open": false + }); + let mut value = json!({ + "agent": legacy_agent, + "macos": { "agent": legacy_agent }, + "preview": { "agent": legacy_agent }, + "profiles": { + "direct": { "agent": legacy_agent }, + "wrapped": { "settings": { "agent": legacy_agent } } + } + }); + migrate(&mut value); + + for agent in [ + &value["agent"], + &value["macos"]["agent"], + &value["preview"]["agent"], + &value["profiles"]["direct"]["agent"], + &value["profiles"]["wrapped"]["settings"]["agent"], + ] { + assert_eq!( + agent, + &json!({ + "threads_sidebar": { "position": "right", "default_width": 420, "auto_open": false } + }) + ); + } + } +} diff --git a/crates/migrator/src/migrator.rs b/crates/migrator/src/migrator.rs index 4ff83ce01e..b184f25adc 100644 --- a/crates/migrator/src/migrator.rs +++ b/crates/migrator/src/migrator.rs @@ -256,6 +256,7 @@ pub fn migrate_settings(text: &str) -> Result> { MigrationType::Json(migrations::m_2026_08_17::make_git_gutter_width_an_enum), MigrationType::Json(migrations::m_2026_08_26::rename_folder_icons_to_folder_indicator), MigrationType::Json(migrations::m_2026_08_30::nest_markdown_preview_settings), + MigrationType::Json(migrations::m_2026_09_16::nest_agent_threads_sidebar_settings), ]; run_migrations(text, migrations) } @@ -5385,4 +5386,28 @@ mod tests { "#}), ); } + + #[test] + fn test_nest_agent_threads_sidebar_settings_is_registered() { + assert_migrate_settings( + indoc! {r#" + { + "agent": { + "sidebar_side": "right", + "threads_sidebar_default_width": 420 + } + } + "#}, + Some(indoc! {r#" + { + "agent": { + "threads_sidebar": { + "position": "right", + "default_width": 420 + } + } + } + "#}), + ); + } } diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index dea0240446..d8c755e48c 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -36,6 +36,29 @@ pub enum SidebarDockPosition { Right, } +#[with_fallible_options] +#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, Default)] +pub struct ThreadsSidebarSettingsContent { + /// Whether opening a folder in an existing window automatically opens the + /// Threads Sidebar. Applies when `default_open_behavior` or + /// `cli_default_open_behavior` is set to `existing_window`. + /// + /// Default: true + pub auto_open: Option, + /// Where to position the threads sidebar. + /// + /// Default: left + pub position: Option, + /// Default width of the threads sidebar in pixels. + /// + /// Values range from 200 to 800, matching the widths the sidebar can be + /// dragged to. Values outside that range are clamped into it. + /// + /// Default: 300 + #[schemars(range(min = 200, max = 800))] + pub default_width: Option, +} + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] pub enum SidebarSide { #[default] @@ -214,29 +237,23 @@ pub struct AgentSettingsContent { /// /// Default: true pub flexible: Option, - /// Where to position the sidebar holding the threads list and the agent panel. + /// The deprecated version of `threads_sidebar.position`. /// - /// Default: left + /// Don't use this field. + #[schemars(skip)] pub sidebar_side: Option, - /// Default width in pixels for the Threads Sidebar. + /// The deprecated version of `threads_sidebar.default_width`. /// - /// Values range from 200 to 800, matching the widths the sidebar can be - /// dragged to. Values outside that range are clamped into it. - /// - /// Default: 300 - #[schemars(range(min = 200, max = 800))] + /// Don't use this field. + #[schemars(skip)] pub threads_sidebar_default_width: Option, - /// Whether opening a folder in an existing window automatically opens the - /// Threads Sidebar. + /// The deprecated version of `threads_sidebar.auto_open`. /// - /// This applies when a folder opens in an existing window instead of a new - /// one, which happens when `default_open_behavior` or - /// `cli_default_open_behavior` is set to `existing_window`. When disabled, - /// the folder still opens in that window, but the sidebar stays closed - /// until you open it. - /// - /// Default: true + /// Don't use this field. + #[schemars(skip)] pub threads_sidebar_auto_open: Option, + /// Settings for the threads sidebar. + pub threads_sidebar: Option, /// Default fixed width in pixels when the agent panel is docked to the left or right and /// `flexible` is false. /// @@ -392,8 +409,22 @@ impl AgentSettingsContent { self.dock = Some(dock); } - pub fn set_sidebar_side(&mut self, position: SidebarDockPosition) { - self.sidebar_side = Some(position); + pub fn set_threads_sidebar_position(&mut self, position: Option) { + self.sidebar_side = None; + self.threads_sidebar.get_or_insert_default().position = position; + } + + pub fn set_threads_sidebar_default_width( + &mut self, + default_width: Option, + ) { + self.threads_sidebar_default_width = None; + self.threads_sidebar.get_or_insert_default().default_width = default_width; + } + + pub fn set_threads_sidebar_auto_open(&mut self, auto_open: Option) { + self.threads_sidebar_auto_open = None; + self.threads_sidebar.get_or_insert_default().auto_open = auto_open; } pub fn set_flexible_size(&mut self, flexible: bool) { diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 7080baf2a2..972d401b68 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -6829,7 +6829,7 @@ fn panels_page() -> SettingsPage { ] } - fn agent_panel_section() -> [SettingsPageItem; 9] { + fn agent_panel_section() -> [SettingsPageItem; 7] { [ SettingsPageItem::SectionHeader("Agent Panel"), SettingsPageItem::SettingItem(SettingItem { @@ -6890,52 +6890,6 @@ fn panels_page() -> SettingsPage { metadata: None, files: USER, }), - SettingsPageItem::SettingItem(SettingItem { - title: "Threads Sidebar Default Width", - description: "Default width of the Threads Sidebar. Resizing it takes precedence until you double-click the divider to reset.", - field: Box::new(SettingField { - organization_override: None, - json_path: Some("agent.threads_sidebar_default_width"), - pick: |settings_content| { - settings_content - .agent - .as_ref()? - .threads_sidebar_default_width - .as_ref() - }, - write: |settings_content, value, _| { - settings_content - .agent - .get_or_insert_default() - .threads_sidebar_default_width = value; - }, - }), - metadata: None, - files: USER, - }), - SettingsPageItem::SettingItem(SettingItem { - title: "Threads Sidebar Auto Open", - description: "Whether opening a folder in an existing window automatically opens the Threads Sidebar.", - field: Box::new(SettingField { - organization_override: None, - json_path: Some("agent.threads_sidebar_auto_open"), - pick: |settings_content| { - settings_content - .agent - .as_ref()? - .threads_sidebar_auto_open - .as_ref() - }, - write: |settings_content, value, _| { - settings_content - .agent - .get_or_insert_default() - .threads_sidebar_auto_open = value; - }, - }), - metadata: None, - files: USER, - }), SettingsPageItem::SettingItem(SettingItem { title: "Agent Panel Default Height", description: "Default height when the agent panel is docked to the bottom.", @@ -8641,7 +8595,7 @@ fn collaboration_page() -> SettingsPage { } fn ai_page(cx: &App) -> SettingsPage { - fn general_section() -> [SettingsPageItem; 6] { + fn general_section() -> [SettingsPageItem; 8] { [ SettingsPageItem::SectionHeader("General"), SettingsPageItem::SettingItem(SettingItem { @@ -8659,14 +8613,75 @@ fn ai_page(cx: &App) -> SettingsPage { files: USER | PROJECT, }), SettingsPageItem::SettingItem(SettingItem { - title: "Threads Sidebar Side", - description: "Which side of the window the threads sidebar appears on.", + title: "Threads Sidebar Position", + description: "Which side of the window the Threads Sidebar appears on.", field: Box::new(SettingField { organization_override: None, - json_path: Some("agent.sidebar_side"), - pick: |settings_content| settings_content.agent.as_ref()?.sidebar_side.as_ref(), + json_path: Some("agent.threads_sidebar.position"), + pick: |settings_content| { + settings_content + .agent + .as_ref()? + .threads_sidebar + .as_ref()? + .position + .as_ref() + }, write: |settings_content, value, _| { - settings_content.agent.get_or_insert_default().sidebar_side = value; + settings_content + .agent + .get_or_insert_default() + .set_threads_sidebar_position(value); + }, + }), + metadata: None, + files: USER, + }), + SettingsPageItem::SettingItem(SettingItem { + title: "Threads Sidebar Default Width", + description: "Default width of the Threads Sidebar. Changing this setting also updates a manually resized sidebar. Double-click the divider to reset to this width.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("agent.threads_sidebar.default_width"), + pick: |settings_content| { + settings_content + .agent + .as_ref()? + .threads_sidebar + .as_ref()? + .default_width + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .agent + .get_or_insert_default() + .set_threads_sidebar_default_width(value); + }, + }), + metadata: None, + files: USER, + }), + SettingsPageItem::SettingItem(SettingItem { + title: "Threads Sidebar Auto Open", + description: "Whether opening a folder in an existing window automatically opens the Threads Sidebar.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("agent.threads_sidebar.auto_open"), + pick: |settings_content| { + settings_content + .agent + .as_ref()? + .threads_sidebar + .as_ref()? + .auto_open + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .agent + .get_or_insert_default() + .set_threads_sidebar_auto_open(value); }, }), metadata: None, diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index caa7785ba8..88a0065585 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -125,7 +125,7 @@ struct SerializedSidebar { /// /// Legacy state recorded every width without this flag. A width other than /// the old default of 300 pixels still identifies a manual resize. Only - /// that ambiguous default falls back to `agent.threads_sidebar_default_width`. + /// that ambiguous default falls back to `agent.threads_sidebar.default_width`. #[serde(default)] width_set_by_user: bool, #[serde(default)] @@ -767,7 +767,7 @@ pub struct Sidebar { multi_workspace: WeakEntity, width: Pixels, /// Whether `width` came from the user rather than from - /// `agent.threads_sidebar_default_width`. Only a user-chosen width is persisted, so + /// `agent.threads_sidebar.default_width`. Only a user-chosen width is persisted, so /// that changing the setting is not overridden by a width the user never /// picked. Serialization runs on many triggers besides resizing. width_set_by_user: bool, @@ -840,11 +840,14 @@ impl Sidebar { AgentThreadWorktreeLabelFlag::watch(cx); - cx.observe_global::(|this, cx| { - let width = AgentSettings::get_global(cx).threads_sidebar_default_width; - if !this.width_set_by_user && this.width != width { - this.width = width; - cx.notify(); + let mut previous_default_width = + AgentSettings::get_global(cx).threads_sidebar.default_width; + cx.observe_global::(move |this, cx| { + let width = AgentSettings::get_global(cx).threads_sidebar.default_width; + if previous_default_width != width { + previous_default_width = width; + this.set_width(None, cx); + this.serialize(cx); } }) .detach(); @@ -934,7 +937,7 @@ impl Sidebar { Self { multi_workspace: multi_workspace.downgrade(), - width: AgentSettings::get_global(cx).threads_sidebar_default_width, + width: AgentSettings::get_global(cx).threads_sidebar.default_width, width_set_by_user: false, focus_handle, filter_editor, @@ -7814,7 +7817,7 @@ impl WorkspaceSidebar for Sidebar { // `None` is the reset gesture, which hands the width back to the setting. self.width_set_by_user = width.is_some(); self.width = width - .unwrap_or_else(|| AgentSettings::get_global(cx).threads_sidebar_default_width) + .unwrap_or_else(|| AgentSettings::get_global(cx).threads_sidebar.default_width) .clamp(THREADS_LIST_MIN_WIDTH, THREADS_LIST_MAX_WIDTH); cx.notify(); } diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index a49eeb7cf1..941e1a3f2d 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -302,7 +302,7 @@ fn set_threads_sidebar_default_width(width: f32, cx: &mut App) { SettingsStore::update_global(cx, |store, cx| { store .set_user_settings( - &format!(r#"{{"agent": {{"threads_sidebar_default_width": {width}}}}}"#), + &format!(r#"{{"agent": {{"threads_sidebar": {{"default_width": {width}}}}}}}"#), cx, ) .unwrap(); @@ -915,7 +915,7 @@ async fn test_width_reset_returns_configured_default(cx: &mut TestAppContext) { } #[gpui::test] -async fn test_width_follows_settings_until_manually_resized(cx: &mut TestAppContext) { +async fn test_width_setting_overrides_manual_resize(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); @@ -929,15 +929,24 @@ async fn test_width_follows_settings_until_manually_resized(cx: &mut TestAppCont sidebar.update_in(cx, |sidebar, _window, cx| { sidebar.set_width(Some(px(420.0)), cx); }); - cx.update(|_window, cx| set_threads_sidebar_default_width(500.0, cx)); - cx.run_until_parked(); - assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.width), px(420.0)); - - sidebar.update_in(cx, |sidebar, _window, cx| { - sidebar.set_width(None, cx); + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store + .set_user_settings( + r#"{"agent":{"threads_sidebar":{"default_width":360,"position":"right"}}}"#, + cx, + ) + .expect("settings are valid"); + }); }); cx.run_until_parked(); + assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.width), px(420.0)); + assert!(sidebar.read_with(cx, |sidebar, _| sidebar.width_set_by_user)); + + cx.update(|_window, cx| set_threads_sidebar_default_width(500.0, cx)); + cx.run_until_parked(); assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.width), px(500.0)); + assert!(!sidebar.read_with(cx, |sidebar, _| sidebar.width_set_by_user)); for (configured, expected) in [ (5.0, THREADS_LIST_MIN_WIDTH), @@ -996,10 +1005,8 @@ async fn test_restored_width_preserves_legacy_resizes(cx: &mut TestAppContext) { cx.update(|_window, cx| set_threads_sidebar_default_width(500.0, cx)); cx.run_until_parked(); - assert_eq!( - sidebar.read_with(cx, |sidebar, _| sidebar.width), - px(if width_set_by_user { expected } else { 500.0 }) - ); + assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.width), px(500.0)); + assert!(!sidebar.read_with(cx, |sidebar, _| sidebar.width_set_by_user)); } } diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index acd83f753c..0946495883 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -70,7 +70,7 @@ pub fn sidebar_side_context_menu( id: impl Into, cx: &App, ) -> ui::RightClickMenu { - let current_position = AgentSettings::get_global(cx).sidebar_side; + let current_position = AgentSettings::get_global(cx).threads_sidebar.position; right_click_menu(id).menu(move |window, cx| { let fs = ::global(cx); ContextMenu::build(window, cx, move |mut menu, _, _cx| { @@ -95,7 +95,7 @@ pub fn sidebar_side_context_menu( settings .agent .get_or_insert_default() - .set_sidebar_side(position); + .set_threads_sidebar_position(Some(position)); }); }, ); diff --git a/crates/workspace/src/multi_workspace_tests.rs b/crates/workspace/src/multi_workspace_tests.rs index f708991e42..91abd7d548 100644 --- a/crates/workspace/src/multi_workspace_tests.rs +++ b/crates/workspace/src/multi_workspace_tests.rs @@ -368,7 +368,7 @@ async fn test_open_directory_in_existing_window_respects_auto_open_setting( cx.update(|cx| { let mut settings = AgentSettings::get_global(cx).clone(); - settings.threads_sidebar_auto_open = false; + settings.threads_sidebar.auto_open = false; AgentSettings::override_global(settings, cx); }); @@ -391,7 +391,7 @@ async fn test_open_directory_in_existing_window_respects_auto_open_setting( .read_with(cx, |mw, _cx| { assert!( !mw.sidebar_open(), - "the sidebar must stay closed when `threads_sidebar_auto_open` is disabled", + "the sidebar must stay closed when `threads_sidebar.auto_open` is disabled", ); assert_eq!( mw.workspaces().count(), diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 8bebe70702..2c613764cb 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -11178,7 +11178,7 @@ pub fn open_paths( open_options.requesting_window = Some(window); window .update(cx, |multi_workspace, _, cx| { - if AgentSettings::get_global(cx).threads_sidebar_auto_open { + if AgentSettings::get_global(cx).threads_sidebar.auto_open { multi_workspace.open_sidebar(cx); } else { // Opening the sidebar is also what pins the diff --git a/docs/src/ai/parallel-agents.md b/docs/src/ai/parallel-agents.md index 9cf27c30e4..a23c1d4b86 100644 --- a/docs/src/ai/parallel-agents.md +++ b/docs/src/ai/parallel-agents.md @@ -19,9 +19,11 @@ Terminal Threads also appear as entries in the sidebar alongside agent threads, To focus the sidebar without toggling it, use {#kb multi_workspace::FocusWorkspaceSidebar}. To search your threads, press {#kb agents_sidebar::FocusSidebarFilter} while the sidebar is focused. -To change the sidebar’s default width, open the Settings Editor and search for “Threads Sidebar Default Width”. See [`agent.threads_sidebar_default_width`](../reference/all-settings.md#agent-threads-sidebar-default-width) for values and a configuration example. +To change the sidebar’s position, open the Settings Editor and search for “Threads Sidebar Position”, which writes `agent.threads_sidebar.position`. To change its default width, search for “Threads Sidebar Default Width”. See [`agent.threads_sidebar.default_width`](../reference/all-settings.md#agent-threads-sidebar-default-width) for values and a configuration example. -Drag the divider to override the configured width. Double-click it to return to the setting and follow subsequent settings changes. +Drag the divider to resize the sidebar. Changing the default width updates even a manually resized sidebar. Double-click the divider to return to the configured width. + +The “Threads Sidebar Auto Open” setting controls whether opening a folder in an existing window opens the sidebar. Its JSON key is `agent.threads_sidebar.auto_open`. ### Switching Threads {#switching-threads} diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 2096aadeef..7c11dae389 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -121,10 +121,32 @@ To use a fixed reset width, disable flexible sizing in the Settings Editor. Or a See [Agent Panel visual customization](../visual-customization.md#agent-panel) for other panel appearance settings. +### Threads Sidebar Position {#agent-threads-sidebar-position} + +- Description: Which side of the window displays the [Threads Sidebar](../ai/parallel-agents.md#threads-sidebar). +- Setting: `agent.threads_sidebar.position` +- Default: `"left"` + +**Options** + +`"left"` or `"right"`. + +Open the Settings Editor and search for “Threads Sidebar Position”. Or add this to your `settings.json`: + +```json [settings] +{ + "agent": { + "threads_sidebar": { + "position": "right" + } + } +} +``` + ### Threads Sidebar Default Width {#agent-threads-sidebar-default-width} - Description: Default width in pixels of the [Threads Sidebar](../ai/parallel-agents.md#threads-sidebar). -- Setting: `agent.threads_sidebar_default_width` +- Setting: `agent.threads_sidebar.default_width` - Default: `300` **Options** @@ -136,19 +158,21 @@ Open the Settings Editor and search for “Threads Sidebar Default Width”. Or ```json [settings] { "agent": { - "threads_sidebar_default_width": 360 + "threads_sidebar": { + "default_width": 360 + } } } ``` -If you haven't manually resized the sidebar, its width follows changes to this setting immediately. A manually resized width takes precedence until you double-click the divider to reset it. After resetting, the sidebar follows this setting again. +Changing this setting immediately updates the sidebar width, even if you previously resized it manually. You can also double-click the divider to reset the sidebar to the configured width. Widths saved by older versions of Zed are preserved if they differ from the previous default of 300 pixels. A saved width of 300 pixels uses this setting instead. ### Threads Sidebar Auto Open {#agent-threads-sidebar-auto-open} - Description: Whether opening a folder in an existing window automatically opens the [Threads Sidebar](../ai/parallel-agents.md#threads-sidebar). -- Setting: `agent.threads_sidebar_auto_open` +- Setting: `agent.threads_sidebar.auto_open` - Default: `true` **Options** @@ -162,7 +186,9 @@ Open the Settings Editor and search for “Threads Sidebar Auto Open”. Or add ```json [settings] { "agent": { - "threads_sidebar_auto_open": false + "threads_sidebar": { + "auto_open": false + } } } ```