From 8b0d49f47426a58cb245306a3403e74973136650 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Mon, 30 Mar 2026 18:39:47 -0700 Subject: [PATCH] Add existing user agent onboarding flow (#52787) 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 --- crates/agent_settings/src/agent_settings.rs | 159 ++++++++++++++++---- crates/agent_ui/src/agent_ui.rs | 134 ++++++++++++++++- crates/eval_cli/src/headless.rs | 1 + crates/zed/src/main.rs | 3 + crates/zed/src/visual_test_runner.rs | 1 + crates/zed/src/zed.rs | 1 + 6 files changed, 271 insertions(+), 28 deletions(-) diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 33a9591b84f907793b4e2bf2c178d7b0e9649d2e..7f51bd8ea5b9b8864663fbf9dc95beedb643d480 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -103,6 +103,29 @@ impl PanelLayout { self.notification_panel_button; } } + + fn backfill_to(&self, user_layout: &PanelLayout, settings: &mut SettingsContent) { + if user_layout.agent_dock.is_none() { + settings.agent.get_or_insert_default().dock = self.agent_dock; + } + if user_layout.project_panel_dock.is_none() { + settings.project_panel.get_or_insert_default().dock = self.project_panel_dock; + } + if user_layout.outline_panel_dock.is_none() { + settings.outline_panel.get_or_insert_default().dock = self.outline_panel_dock; + } + if user_layout.collaboration_panel_dock.is_none() { + settings.collaboration_panel.get_or_insert_default().dock = + self.collaboration_panel_dock; + } + if user_layout.git_panel_dock.is_none() { + settings.git_panel.get_or_insert_default().dock = self.git_panel_dock; + } + if user_layout.notification_panel_button.is_none() { + settings.notification_panel.get_or_insert_default().button = + self.notification_panel_button; + } + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -219,6 +242,18 @@ impl AgentSettings { WindowLayout::Custom(user_layout) } + pub fn backfill_editor_layout(fs: Arc, cx: &App) { + let user_layout = cx + .global::() + .raw_user_settings() + .map(|u| PanelLayout::read_from(u.content.as_ref())) + .unwrap_or_default(); + + update_settings_file(fs, cx, move |settings, _cx| { + PanelLayout::EDITOR.backfill_to(&user_layout, settings); + }); + } + pub fn set_layout(layout: WindowLayout, fs: Arc, cx: &App) { let merged = PanelLayout::read_from(cx.global::().merged_settings()); @@ -704,6 +739,14 @@ mod tests { use settings::ToolPermissionMode; use settings::ToolPermissionsContent; + fn set_agent_v2_defaults(cx: &mut gpui::App) { + SettingsStore::update_global(cx, |store, cx| { + store.update_default_settings(cx, |defaults| { + PanelLayout::AGENT.write_to(defaults); + }); + }); + } + #[test] fn test_compiled_regex_case_insensitive() { let regex = CompiledRegex::new("rm\\s+-rf", false).unwrap(); @@ -1184,22 +1227,8 @@ mod tests { 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); - }); - }); + // Test defaults are editor layout; switch to agent V2. + set_agent_v2_defaults(cx); // Should be Agent with an empty user layout (user hasn't customized). let layout = AgentSettings::get_layout(cx); @@ -1335,17 +1364,7 @@ mod tests { 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); - }); - }); + set_agent_v2_defaults(cx); // User has agent=left (matches preset) and project_panel=left (does not) SettingsStore::update_global(cx, |store, cx| { @@ -1392,4 +1411,90 @@ mod tests { assert!(matches!(layout, WindowLayout::Agent(_))); }); } + + #[gpui::test] + async fn test_backfill_editor_layout(cx: &mut TestAppContext) { + let fs = fs::FakeFs::new(cx.background_executor.clone()); + // User has only customized project_panel to "right". + fs.save( + paths::settings_file().as_path(), + &serde_json::json!({ + "project_panel": { "dock": "right" } + }) + .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); + + // Simulate pre-migration state: editor defaults (the old world). + SettingsStore::update_global(cx, |store, cx| { + store.update_default_settings(cx, |defaults| { + PanelLayout::EDITOR.write_to(defaults); + }); + }); + + // User has only customized project_panel to "right". + SettingsStore::update_global(cx, |store, cx| { + store + .set_user_settings(r#"{ "project_panel": { "dock": "right" } }"#, cx) + .unwrap(); + }); + + // Run the one-time backfill while still on old defaults. + AgentSettings::backfill_editor_layout(fs.clone(), cx); + }); + + cx.run_until_parked(); + + // Read back the file and apply it, then switch to agent V2 defaults. + 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's project_panel=right should be preserved (they set it). + // All other fields should now have the editor preset values + // written into user settings. + let store = cx.global::(); + 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::Right)); + assert_eq!(user_layout.project_panel_dock, Some(DockSide::Right)); + assert_eq!(user_layout.outline_panel_dock, Some(DockSide::Left)); + assert_eq!( + user_layout.collaboration_panel_dock, + Some(DockPosition::Left) + ); + assert_eq!(user_layout.git_panel_dock, Some(DockPosition::Left)); + assert_eq!(user_layout.notification_panel_button, Some(true)); + + // Now switch defaults to agent V2. + set_agent_v2_defaults(cx); + + // Even though defaults are now agent, the backfilled user settings + // keep everything in the editor layout. The user's experience + // hasn't changed. + let layout = AgentSettings::get_layout(cx); + let WindowLayout::Custom(user_layout) = layout else { + panic!( + "expected Custom (editor values override agent defaults), got {:?}", + layout + ); + }; + assert_eq!(user_layout.agent_dock, Some(DockPosition::Right)); + assert_eq!(user_layout.project_panel_dock, Some(DockSide::Right)); + }); + } } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 0e5eb39360af434936c510a589c2d1f2b6fa74b2..175f7e05f5ee824239fc179e12ca56aa9f2e1c74 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -48,7 +48,7 @@ use client::Client; use command_palette_hooks::CommandPaletteFilter; use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _}; use fs::Fs; -use gpui::{Action, App, Context, Entity, SharedString, UpdateGlobal, Window, actions}; +use gpui::{Action, App, Context, Entity, SharedString, UpdateGlobal as _, Window, actions}; use language::{ LanguageRegistry, language_settings::{AllLanguageSettings, EditPredictionProvider}, @@ -82,6 +82,7 @@ pub(crate) use thread_history_view::*; use zed_actions; pub const DEFAULT_THREAD_TITLE: &str = "New Thread"; +const PARALLEL_AGENT_LAYOUT_BACKFILL_KEY: &str = "parallel_agent_layout_backfilled"; actions!( agent, @@ -354,6 +355,7 @@ pub fn init( client: Arc, prompt_builder: Arc, language_registry: Arc, + is_new_install: bool, is_eval: bool, cx: &mut App, ) { @@ -427,6 +429,9 @@ pub fn init( }) .detach(); + // TODO: remove this field when we're ready remove the feature flag + maybe_backfill_editor_layout(fs, is_new_install, false, cx); + cx.observe_flag::(|is_enabled, cx| { SettingsStore::update_global(cx, |store, cx| { store.update_default_settings(cx, |defaults| { @@ -453,6 +458,37 @@ pub fn init( .detach(); } +fn maybe_backfill_editor_layout( + fs: Arc, + is_new_install: bool, + should_run: bool, + cx: &mut App, +) { + if !should_run { + return; + } + + let kvp = db::kvp::KeyValueStore::global(cx); + let already_backfilled = + util::ResultExt::log_err(kvp.read_kvp(PARALLEL_AGENT_LAYOUT_BACKFILL_KEY)) + .flatten() + .is_some(); + + if !already_backfilled { + if !is_new_install { + AgentSettings::backfill_editor_layout(fs, cx); + } + + db::write_and_log(cx, move || async move { + kvp.write_kvp( + PARALLEL_AGENT_LAYOUT_BACKFILL_KEY.to_string(), + "1".to_string(), + ) + .await + }); + } +} + fn update_command_palette_filter(cx: &mut App) { let disable_ai = DisableAiSettings::get_global(cx).disable_ai; let agent_enabled = AgentSettings::get_global(cx).enabled; @@ -624,7 +660,9 @@ mod tests { use super::*; use agent_settings::{AgentProfileId, AgentSettings}; use command_palette_hooks::CommandPaletteFilter; + use db::kvp::KeyValueStore; use editor::actions::AcceptEditPrediction; + use feature_flags::FeatureFlagAppExt; use gpui::{BorrowAppContext, TestAppContext, px}; use project::DisableAiSettings; use settings::{ @@ -767,6 +805,100 @@ mod tests { }); } + async fn setup_backfill_test(cx: &mut TestAppContext) -> Arc { + let fs = fs::FakeFs::new(cx.background_executor.clone()); + fs.save( + paths::settings_file().as_path(), + &"{}".into(), + Default::default(), + ) + .await + .unwrap(); + + cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + AgentSettings::register(cx); + DisableAiSettings::register(cx); + cx.set_staff(true); + }); + + fs + } + + #[gpui::test] + async fn test_backfill_sets_kvp_flag(cx: &mut TestAppContext) { + let fs = setup_backfill_test(cx).await; + + cx.update(|cx| { + let kvp = KeyValueStore::global(cx); + assert!( + kvp.read_kvp(PARALLEL_AGENT_LAYOUT_BACKFILL_KEY) + .unwrap() + .is_none() + ); + + maybe_backfill_editor_layout(fs.clone(), false, true, cx); + }); + + cx.run_until_parked(); + + let kvp = cx.update(|cx| KeyValueStore::global(cx)); + assert!( + kvp.read_kvp(PARALLEL_AGENT_LAYOUT_BACKFILL_KEY) + .unwrap() + .is_some(), + "flag should be set after backfill" + ); + } + + #[gpui::test] + async fn test_backfill_new_install_sets_flag_without_writing_settings(cx: &mut TestAppContext) { + let fs = setup_backfill_test(cx).await; + + cx.update(|cx| { + maybe_backfill_editor_layout(fs.clone(), true, true, cx); + }); + + cx.run_until_parked(); + + let kvp = cx.update(|cx| KeyValueStore::global(cx)); + assert!( + kvp.read_kvp(PARALLEL_AGENT_LAYOUT_BACKFILL_KEY) + .unwrap() + .is_some(), + "flag should be set even for new installs" + ); + + let written = fs.load(paths::settings_file().as_path()).await.unwrap(); + assert_eq!(written.trim(), "{}", "settings file should be unchanged"); + } + + #[gpui::test] + async fn test_backfill_is_idempotent(cx: &mut TestAppContext) { + let fs = setup_backfill_test(cx).await; + + cx.update(|cx| { + maybe_backfill_editor_layout(fs.clone(), false, true, cx); + }); + + cx.run_until_parked(); + + let after_first = fs.load(paths::settings_file().as_path()).await.unwrap(); + + cx.update(|cx| { + maybe_backfill_editor_layout(fs.clone(), false, true, cx); + }); + + cx.run_until_parked(); + + let after_second = fs.load(paths::settings_file().as_path()).await.unwrap(); + assert_eq!( + after_first, after_second, + "second call should not change settings" + ); + } + #[test] fn test_deserialize_external_agent_variants() { assert_eq!( diff --git a/crates/eval_cli/src/headless.rs b/crates/eval_cli/src/headless.rs index 0e2e40835fa3507ee20e6f1c6cf01226724451c1..f1c8830b11a195df120f3b54aa466d27ce708568 100644 --- a/crates/eval_cli/src/headless.rs +++ b/crates/eval_cli/src/headless.rs @@ -122,6 +122,7 @@ pub fn init(cx: &mut App) -> Arc { prompt_builder, languages.clone(), true, + true, cx, ); diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 25e63abe6ce6b715fa3f5b5e12010f46ac1d4714..764a89d507c590f9d5a1f4b7ce40b30795fa450b 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -598,6 +598,8 @@ fn main() { }) .detach(); + let is_new_install = matches!(&installation_id, Some(IdType::New(_))); + // We should rename these in the future to `first app open`, `first app open for release channel`, and `app open` if let (Some(system_id), Some(installation_id)) = (&system_id, &installation_id) { match (&system_id, &installation_id) { @@ -683,6 +685,7 @@ fn main() { app_state.client.clone(), prompt_builder.clone(), app_state.languages.clone(), + is_new_install, false, cx, ); diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index d0c0a2a2a5c443d493a568186dd090cc967cad64..57fbeeb9a991705ca1f9ae6cf00b9a17a41d822f 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -214,6 +214,7 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()> app_state.client.clone(), prompt_builder, app_state.languages.clone(), + true, false, cx, ); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 8f7e812a427593ca73aef6a7034481a28b42af9f..992ce084e83aaa36c087d1273ab878b77c9beea3 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -5045,6 +5045,7 @@ mod tests { app_state.client.clone(), prompt_builder.clone(), app_state.languages.clone(), + true, false, cx, );