assistant2: Add support for forking existing profiles (#27627)

Marshall Bowers created

This PR adds support for forking existing profiles from the manage
profiles modal.


https://github.com/user-attachments/assets/5fa9b76c-fafe-4c72-8843-576c4b5ca2f2

Release Notes:

- N/A

Change summary

Cargo.lock                                                                                  |   1 
crates/assistant2/Cargo.toml                                                                |   1 
crates/assistant2/src/assistant_configuration/manage_profiles_modal.rs                      | 278 
crates/assistant2/src/assistant_configuration/manage_profiles_modal/profile_modal_header.rs |  38 
crates/assistant2/src/assistant_configuration/profile_picker.rs                             |   2 
crates/ui/src/traits/styled_ext.rs                                                          |   2 
6 files changed, 283 insertions(+), 39 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -459,6 +459,7 @@ dependencies = [
  "collections",
  "command_palette_hooks",
  "context_server",
+ "convert_case 0.8.0",
  "db",
  "editor",
  "feature_flags",

crates/assistant2/Cargo.toml 🔗

@@ -31,6 +31,7 @@ clock.workspace = true
 collections.workspace = true
 command_palette_hooks.workspace = true
 context_server.workspace = true
+convert_case.workspace = true
 db.workspace = true
 editor.workspace = true
 feature_flags.workspace = true

crates/assistant2/src/assistant_configuration/manage_profiles_modal.rs 🔗

@@ -1,13 +1,21 @@
+mod profile_modal_header;
+
 use std::sync::Arc;
 
-use assistant_settings::AssistantSettings;
+use assistant_settings::{
+    AgentProfile, AgentProfileContent, AssistantSettings, AssistantSettingsContent,
+    ContextServerPresetContent, VersionedAssistantSettingsContent,
+};
 use assistant_tool::ToolWorkingSet;
+use convert_case::{Case, Casing as _};
+use editor::Editor;
 use fs::Fs;
 use gpui::{prelude::*, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription};
-use settings::Settings as _;
-use ui::{prelude::*, ListItem, ListItemSpacing, Navigable, NavigableEntry};
+use settings::{update_settings_file, Settings as _};
+use ui::{prelude::*, ListItem, ListItemSpacing, ListSeparator, Navigable, NavigableEntry};
 use workspace::{ModalView, Workspace};
 
+use crate::assistant_configuration::manage_profiles_modal::profile_modal_header::ProfileModalHeader;
 use crate::assistant_configuration::profile_picker::{ProfilePicker, ProfilePickerDelegate};
 use crate::assistant_configuration::tool_picker::{ToolPicker, ToolPickerDelegate};
 use crate::{AssistantPanel, ManageProfiles};
@@ -17,8 +25,10 @@ enum Mode {
         profile_picker: Entity<ProfilePicker>,
         _subscription: Subscription,
     },
+    NewProfile(NewProfileMode),
     ViewProfile(ViewProfileMode),
     ConfigureTools {
+        profile_id: Arc<str>,
         tool_picker: Entity<ToolPicker>,
         _subscription: Subscription,
     },
@@ -57,9 +67,16 @@ impl Mode {
 #[derive(Clone)]
 pub struct ViewProfileMode {
     profile_id: Arc<str>,
+    fork_profile: NavigableEntry,
     configure_tools: NavigableEntry,
 }
 
+#[derive(Clone)]
+pub struct NewProfileMode {
+    name_editor: Entity<Editor>,
+    base_profile_id: Option<Arc<str>>,
+}
+
 pub struct ManageProfilesModal {
     fs: Arc<dyn Fs>,
     tools: Arc<ToolWorkingSet>,
@@ -104,6 +121,24 @@ impl ManageProfilesModal {
         self.focus_handle(cx).focus(window);
     }
 
+    fn new_profile(
+        &mut self,
+        base_profile_id: Option<Arc<str>>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let name_editor = cx.new(|cx| Editor::single_line(window, cx));
+        name_editor.update(cx, |editor, cx| {
+            editor.set_placeholder_text("Profile name", cx);
+        });
+
+        self.mode = Mode::NewProfile(NewProfileMode {
+            name_editor,
+            base_profile_id,
+        });
+        self.focus_handle(cx).focus(window);
+    }
+
     pub fn view_profile(
         &mut self,
         profile_id: Arc<str>,
@@ -112,6 +147,7 @@ impl ManageProfilesModal {
     ) {
         self.mode = Mode::ViewProfile(ViewProfileMode {
             profile_id,
+            fork_profile: NavigableEntry::focusable(cx),
             configure_tools: NavigableEntry::focusable(cx),
         });
         self.focus_handle(cx).focus(window);
@@ -146,21 +182,97 @@ impl ManageProfilesModal {
         });
 
         self.mode = Mode::ConfigureTools {
+            profile_id,
             tool_picker,
             _subscription: dismiss_subscription,
         };
         self.focus_handle(cx).focus(window);
     }
 
-    fn confirm(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
+    fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        match &self.mode {
+            Mode::ChooseProfile { .. } => {}
+            Mode::NewProfile(mode) => {
+                let settings = AssistantSettings::get_global(cx);
+
+                let base_profile = mode
+                    .base_profile_id
+                    .as_ref()
+                    .and_then(|profile_id| settings.profiles.get(profile_id).cloned());
+
+                let name = mode.name_editor.read(cx).text(cx);
+                let profile_id: Arc<str> = name.to_case(Case::Kebab).into();
+
+                let profile = AgentProfile {
+                    name: name.into(),
+                    tools: base_profile
+                        .as_ref()
+                        .map(|profile| profile.tools.clone())
+                        .unwrap_or_default(),
+                    context_servers: base_profile
+                        .map(|profile| profile.context_servers)
+                        .unwrap_or_default(),
+                };
+
+                self.create_profile(profile_id.clone(), profile, cx);
+                self.view_profile(profile_id, window, cx);
+            }
+            Mode::ViewProfile(_) => {}
+            Mode::ConfigureTools { .. } => {}
+        }
+    }
 
     fn cancel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         match &self.mode {
             Mode::ChooseProfile { .. } => {}
+            Mode::NewProfile(mode) => {
+                if let Some(profile_id) = mode.base_profile_id.clone() {
+                    self.view_profile(profile_id, window, cx);
+                } else {
+                    self.choose_profile(window, cx);
+                }
+            }
             Mode::ViewProfile(_) => self.choose_profile(window, cx),
             Mode::ConfigureTools { .. } => {}
         }
     }
+
+    fn create_profile(&self, profile_id: Arc<str>, profile: AgentProfile, cx: &mut Context<Self>) {
+        update_settings_file::<AssistantSettings>(self.fs.clone(), cx, {
+            move |settings, _cx| match settings {
+                AssistantSettingsContent::Versioned(VersionedAssistantSettingsContent::V2(
+                    settings,
+                )) => {
+                    let profiles = settings.profiles.get_or_insert_default();
+                    if profiles.contains_key(&profile_id) {
+                        log::error!("profile with ID '{profile_id}' already exists");
+                        return;
+                    }
+
+                    profiles.insert(
+                        profile_id,
+                        AgentProfileContent {
+                            name: profile.name.into(),
+                            tools: profile.tools,
+                            context_servers: profile
+                                .context_servers
+                                .into_iter()
+                                .map(|(server_id, preset)| {
+                                    (
+                                        server_id,
+                                        ContextServerPresetContent {
+                                            tools: preset.tools,
+                                        },
+                                    )
+                                })
+                                .collect(),
+                        },
+                    );
+                }
+                _ => {}
+            }
+        });
+    }
 }
 
 impl ModalView for ManageProfilesModal {}
@@ -169,8 +281,9 @@ impl Focusable for ManageProfilesModal {
     fn focus_handle(&self, cx: &App) -> FocusHandle {
         match &self.mode {
             Mode::ChooseProfile { profile_picker, .. } => profile_picker.focus_handle(cx),
-            Mode::ConfigureTools { tool_picker, .. } => tool_picker.focus_handle(cx),
+            Mode::NewProfile(mode) => mode.name_editor.focus_handle(cx),
             Mode::ViewProfile(_) => self.focus_handle.clone(),
+            Mode::ConfigureTools { tool_picker, .. } => tool_picker.focus_handle(cx),
         }
     }
 }
@@ -178,55 +291,122 @@ impl Focusable for ManageProfilesModal {
 impl EventEmitter<DismissEvent> for ManageProfilesModal {}
 
 impl ManageProfilesModal {
+    fn render_new_profile(
+        &mut self,
+        mode: NewProfileMode,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
+        v_flex()
+            .id("new-profile")
+            .track_focus(&self.focus_handle(cx))
+            .child(h_flex().p_2().child(mode.name_editor.clone()))
+    }
+
     fn render_view_profile(
         &mut self,
         mode: ViewProfileMode,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> impl IntoElement {
+        let settings = AssistantSettings::get_global(cx);
+
+        let profile_name = settings
+            .profiles
+            .get(&mode.profile_id)
+            .map(|profile| profile.name.clone())
+            .unwrap_or_else(|| "Unknown".into());
+
         Navigable::new(
             div()
                 .track_focus(&self.focus_handle(cx))
                 .size_full()
+                .child(ProfileModalHeader::new(
+                    profile_name,
+                    IconName::ZedAssistant,
+                ))
                 .child(
-                    v_flex().child(
-                        div()
-                            .id("configure-tools")
-                            .track_focus(&mode.configure_tools.focus_handle)
-                            .on_action({
-                                let profile_id = mode.profile_id.clone();
-                                cx.listener(move |this, _: &menu::Confirm, window, cx| {
-                                    this.configure_tools(profile_id.clone(), window, cx);
+                    v_flex()
+                        .pb_1()
+                        .child(ListSeparator)
+                        .child(
+                            div()
+                                .id("fork-profile")
+                                .track_focus(&mode.fork_profile.focus_handle)
+                                .on_action({
+                                    let profile_id = mode.profile_id.clone();
+                                    cx.listener(move |this, _: &menu::Confirm, window, cx| {
+                                        this.new_profile(Some(profile_id.clone()), window, cx);
+                                    })
                                 })
-                            })
-                            .child(
-                                ListItem::new("configure-tools")
-                                    .toggle_state(
-                                        mode.configure_tools
-                                            .focus_handle
-                                            .contains_focused(window, cx),
-                                    )
-                                    .inset(true)
-                                    .spacing(ListItemSpacing::Sparse)
-                                    .start_slot(Icon::new(IconName::Cog))
-                                    .child(Label::new("Configure Tools"))
-                                    .on_click({
-                                        let profile_id = mode.profile_id.clone();
-                                        cx.listener(move |this, _, window, cx| {
-                                            this.configure_tools(profile_id.clone(), window, cx);
-                                        })
-                                    }),
-                            ),
-                    ),
+                                .child(
+                                    ListItem::new("fork-profile")
+                                        .toggle_state(
+                                            mode.fork_profile
+                                                .focus_handle
+                                                .contains_focused(window, cx),
+                                        )
+                                        .inset(true)
+                                        .spacing(ListItemSpacing::Sparse)
+                                        .start_slot(Icon::new(IconName::GitBranch))
+                                        .child(Label::new("Fork Profile"))
+                                        .on_click({
+                                            let profile_id = mode.profile_id.clone();
+                                            cx.listener(move |this, _, window, cx| {
+                                                this.new_profile(
+                                                    Some(profile_id.clone()),
+                                                    window,
+                                                    cx,
+                                                );
+                                            })
+                                        }),
+                                ),
+                        )
+                        .child(
+                            div()
+                                .id("configure-tools")
+                                .track_focus(&mode.configure_tools.focus_handle)
+                                .on_action({
+                                    let profile_id = mode.profile_id.clone();
+                                    cx.listener(move |this, _: &menu::Confirm, window, cx| {
+                                        this.configure_tools(profile_id.clone(), window, cx);
+                                    })
+                                })
+                                .child(
+                                    ListItem::new("configure-tools")
+                                        .toggle_state(
+                                            mode.configure_tools
+                                                .focus_handle
+                                                .contains_focused(window, cx),
+                                        )
+                                        .inset(true)
+                                        .spacing(ListItemSpacing::Sparse)
+                                        .start_slot(Icon::new(IconName::Cog))
+                                        .child(Label::new("Configure Tools"))
+                                        .on_click({
+                                            let profile_id = mode.profile_id.clone();
+                                            cx.listener(move |this, _, window, cx| {
+                                                this.configure_tools(
+                                                    profile_id.clone(),
+                                                    window,
+                                                    cx,
+                                                );
+                                            })
+                                        }),
+                                ),
+                        ),
                 )
                 .into_any_element(),
         )
+        .entry(mode.fork_profile)
         .entry(mode.configure_tools)
     }
 }
 
 impl Render for ManageProfilesModal {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let settings = AssistantSettings::get_global(cx);
+
         div()
             .elevation_3(cx)
             .w(rems(34.))
@@ -238,13 +418,37 @@ impl Render for ManageProfilesModal {
             }))
             .on_mouse_down_out(cx.listener(|_this, _, _, cx| cx.emit(DismissEvent)))
             .child(match &self.mode {
-                Mode::ChooseProfile { profile_picker, .. } => {
-                    profile_picker.clone().into_any_element()
-                }
+                Mode::ChooseProfile { profile_picker, .. } => div()
+                    .child(ProfileModalHeader::new("Profiles", IconName::ZedAssistant))
+                    .child(ListSeparator)
+                    .child(profile_picker.clone())
+                    .into_any_element(),
+                Mode::NewProfile(mode) => self
+                    .render_new_profile(mode.clone(), window, cx)
+                    .into_any_element(),
                 Mode::ViewProfile(mode) => self
                     .render_view_profile(mode.clone(), window, cx)
                     .into_any_element(),
-                Mode::ConfigureTools { tool_picker, .. } => tool_picker.clone().into_any_element(),
+                Mode::ConfigureTools {
+                    profile_id,
+                    tool_picker,
+                    ..
+                } => {
+                    let profile_name = settings
+                        .profiles
+                        .get(profile_id)
+                        .map(|profile| profile.name.clone())
+                        .unwrap_or_else(|| "Unknown".into());
+
+                    div()
+                        .child(ProfileModalHeader::new(
+                            format!("{profile_name}: Configure Tools"),
+                            IconName::Cog,
+                        ))
+                        .child(ListSeparator)
+                        .child(tool_picker.clone())
+                        .into_any_element()
+                }
             })
     }
 }

crates/assistant2/src/assistant_configuration/manage_profiles_modal/profile_modal_header.rs 🔗

@@ -0,0 +1,38 @@
+use ui::prelude::*;
+
+#[derive(IntoElement)]
+pub struct ProfileModalHeader {
+    label: SharedString,
+    icon: IconName,
+}
+
+impl ProfileModalHeader {
+    pub fn new(label: impl Into<SharedString>, icon: IconName) -> Self {
+        Self {
+            label: label.into(),
+            icon,
+        }
+    }
+}
+
+impl RenderOnce for ProfileModalHeader {
+    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+        h_flex()
+            .w_full()
+            .px(DynamicSpacing::Base12.rems(cx))
+            .pt(DynamicSpacing::Base08.rems(cx))
+            .pb(DynamicSpacing::Base04.rems(cx))
+            .rounded_t_sm()
+            .gap_1p5()
+            .child(Icon::new(self.icon).size(IconSize::XSmall))
+            .child(
+                h_flex().gap_1().overflow_x_hidden().child(
+                    div()
+                        .max_w_96()
+                        .overflow_x_hidden()
+                        .text_ellipsis()
+                        .child(Headline::new(self.label).size(HeadlineSize::XSmall)),
+                ),
+            )
+    }
+}

crates/assistant2/src/assistant_configuration/profile_picker.rs 🔗

@@ -21,7 +21,7 @@ impl ProfilePicker {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
-        let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
+        let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false));
         Self { picker }
     }
 }

crates/ui/src/traits/styled_ext.rs 🔗

@@ -74,7 +74,7 @@ pub trait StyledExt: Styled + Sized {
     /// Sets `bg()`, `rounded_lg()`, `border()`, `border_color()`, `shadow()`
     ///
     /// Examples: Settings Modal, Channel Management, Wizards/Setup UI, Dialogs
-    fn elevation_3(self, cx: &mut App) -> Self {
+    fn elevation_3(self, cx: &App) -> Self {
         elevated(self, cx, ElevationIndex::ModalSurface)
     }