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
This commit is contained in:
Mikayla Maki 2026-03-30 18:39:47 -07:00 committed by GitHub
parent 3d29a0641e
commit 8b0d49f474
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 271 additions and 28 deletions

View file

@ -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));
});
}
}

View file

@ -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!(

View file

@ -122,6 +122,7 @@ pub fn init(cx: &mut App) -> Arc<AgentCliAppState> {
prompt_builder,
languages.clone(),
true,
true,
cx,
);

View file

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

View file

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

View file

@ -5045,6 +5045,7 @@ mod tests {
app_state.client.clone(),
prompt_builder.clone(),
app_state.languages.clone(),
true,
false,
cx,
);