Add existing user agent onboarding flow (#52787)

Mikayla Maki created

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

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(-)

Detailed changes

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<dyn Fs>, cx: &App) {
+        let user_layout = cx
+            .global::<SettingsStore>()
+            .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<dyn Fs>, cx: &App) {
         let merged = PanelLayout::read_from(cx.global::<SettingsStore>().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::<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::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));
+        });
+    }
 }

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<Client>,
     prompt_builder: Arc<PromptBuilder>,
     language_registry: Arc<LanguageRegistry>,
+    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::<AgentV2FeatureFlag, _>(|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<dyn Fs>,
+    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<dyn Fs> {
+        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!(

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,
         );

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,
         );

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,
             );