agent_ui: Add keybinding to cycle through new thread location options & settings (#51384)

Danilo Leal created

This PR adds the ability to save in the settings whether new threads
should start in the current project or in a new Git worktree.
Additionally, it also adds a keybinding that allows cycling through the
menu options easily, with the ability to use cmd-click/enter to choose
which one is set as the default.

No release notes because this feature/settings depends on a feature flag
that isn't out yet.

Release Notes:

- N/A

Change summary

assets/keymaps/default-linux.json           |   2 
assets/keymaps/default-macos.json           |   2 
assets/keymaps/default-windows.json         |   2 
crates/agent/src/tool_permissions.rs        |   1 
crates/agent_settings/src/agent_settings.rs |   4 
crates/agent_ui/src/agent_panel.rs          | 125 ++++++++++++++++------
crates/agent_ui/src/agent_ui.rs             |   5 
crates/agent_ui/src/ui/hold_for_default.rs  |  19 ++
crates/settings_content/src/agent.rs        |  21 +++
crates/settings_ui/src/page_data.rs         |  24 ++++
10 files changed, 159 insertions(+), 46 deletions(-)

Detailed changes

assets/keymaps/default-linux.json πŸ”—

@@ -258,7 +258,7 @@
       "ctrl-shift-j": "agent::ToggleNavigationMenu",
       "ctrl-alt-i": "agent::ToggleOptionsMenu",
       "ctrl-alt-shift-n": "agent::ToggleNewThreadMenu",
-      "ctrl-alt-shift-t": "agent::ToggleStartThreadInSelector",
+      "ctrl-shift-t": "agent::CycleStartThreadIn",
       "shift-alt-escape": "agent::ExpandMessageEditor",
       "ctrl->": "agent::AddSelectionToThread",
       "ctrl-shift-e": "project_panel::ToggleFocus",

assets/keymaps/default-macos.json πŸ”—

@@ -297,7 +297,7 @@
       "cmd-shift-j": "agent::ToggleNavigationMenu",
       "cmd-alt-m": "agent::ToggleOptionsMenu",
       "cmd-alt-shift-n": "agent::ToggleNewThreadMenu",
-      "cmd-alt-shift-t": "agent::ToggleStartThreadInSelector",
+      "cmd-shift-t": "agent::CycleStartThreadIn",
       "shift-alt-escape": "agent::ExpandMessageEditor",
       "cmd->": "agent::AddSelectionToThread",
       "cmd-shift-e": "project_panel::ToggleFocus",

assets/keymaps/default-windows.json πŸ”—

@@ -259,7 +259,7 @@
       "shift-alt-j": "agent::ToggleNavigationMenu",
       "shift-alt-i": "agent::ToggleOptionsMenu",
       "ctrl-shift-alt-n": "agent::ToggleNewThreadMenu",
-      "ctrl-shift-alt-t": "agent::ToggleStartThreadInSelector",
+      "ctrl-shift-t": "agent::CycleStartThreadIn",
       "shift-alt-escape": "agent::ExpandMessageEditor",
       "ctrl-shift-.": "agent::AddSelectionToThread",
       "ctrl-shift-e": "project_panel::ToggleFocus",

crates/agent_settings/src/agent_settings.rs πŸ”—

@@ -12,7 +12,7 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::{
     DefaultAgentView, DockPosition, LanguageModelParameters, LanguageModelSelection,
-    NotifyWhenAgentWaiting, RegisterSetting, Settings, ToolPermissionMode,
+    NewThreadLocation, NotifyWhenAgentWaiting, RegisterSetting, Settings, ToolPermissionMode,
 };
 
 pub use crate::agent_profile::*;
@@ -51,6 +51,7 @@ pub struct AgentSettings {
     pub message_editor_min_lines: usize,
     pub show_turn_stats: bool,
     pub tool_permissions: ToolPermissions,
+    pub new_thread_location: NewThreadLocation,
 }
 
 impl AgentSettings {
@@ -438,6 +439,7 @@ impl Settings for AgentSettings {
             message_editor_min_lines: agent.message_editor_min_lines.unwrap(),
             show_turn_stats: agent.show_turn_stats.unwrap(),
             tool_permissions: compile_tool_permissions(agent.tool_permissions),
+            new_thread_location: agent.new_thread_location.unwrap_or_default(),
         }
     }
 }

crates/agent_ui/src/agent_panel.rs πŸ”—

@@ -29,12 +29,12 @@ use zed_actions::agent::{
     ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, ReviewBranchDiff,
 };
 
-use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal};
+use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal, HoldForDefault};
 use crate::{
-    AddContextServer, AgentDiffPane, ConnectionView, CopyThreadToClipboard, Follow,
-    InlineAssistant, LoadThreadFromClipboard, NewTextThread, NewThread, OpenActiveThreadAsMarkdown,
-    OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, StartThreadIn,
-    ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu, ToggleStartThreadInSelector,
+    AddContextServer, AgentDiffPane, ConnectionView, CopyThreadToClipboard, CycleStartThreadIn,
+    Follow, InlineAssistant, LoadThreadFromClipboard, NewTextThread, NewThread,
+    OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell,
+    StartThreadIn, ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu,
     agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
     connection_view::{AcpThreadViewEvent, ThreadView},
     slash_command::SlashCommandCompletionProvider,
@@ -312,18 +312,6 @@ pub fn init(cx: &mut App) {
                         });
                     }
                 })
-                .register_action(|workspace, _: &ToggleStartThreadInSelector, window, cx| {
-                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
-                        workspace.focus_panel::<AgentPanel>(window, cx);
-                        panel.update(cx, |panel, cx| {
-                            panel.toggle_start_thread_in_selector(
-                                &ToggleStartThreadInSelector,
-                                window,
-                                cx,
-                            );
-                        });
-                    }
-                })
                 .register_action(|workspace, _: &OpenAcpOnboardingModal, window, cx| {
                     AcpOnboardingModal::toggle(workspace, window, cx)
                 })
@@ -477,6 +465,13 @@ pub fn init(cx: &mut App) {
                         });
                     }
                 })
+                .register_action(|workspace, _: &CycleStartThreadIn, _window, cx| {
+                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+                        panel.update(cx, |panel, cx| {
+                            panel.cycle_start_thread_in(cx);
+                        });
+                    }
+                })
                 .register_action(|workspace, _: &ToggleWorkspaceSidebar, window, cx| {
                     if !multi_workspace_enabled(cx) {
                         return;
@@ -1751,15 +1746,6 @@ impl AgentPanel {
         self.new_thread_menu_handle.toggle(window, cx);
     }
 
-    pub fn toggle_start_thread_in_selector(
-        &mut self,
-        _: &ToggleStartThreadInSelector,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        self.start_thread_in_menu_handle.toggle(window, cx);
-    }
-
     pub fn increase_font_size(
         &mut self,
         action: &IncreaseBufferFontSize,
@@ -2388,6 +2374,28 @@ impl AgentPanel {
         cx.notify();
     }
 
+    fn cycle_start_thread_in(&mut self, cx: &mut Context<Self>) {
+        let next = match self.start_thread_in {
+            StartThreadIn::LocalProject => StartThreadIn::NewWorktree,
+            StartThreadIn::NewWorktree => StartThreadIn::LocalProject,
+        };
+        self.set_start_thread_in(&next, cx);
+    }
+
+    fn reset_start_thread_in_to_default(&mut self, cx: &mut Context<Self>) {
+        use settings::{NewThreadLocation, Settings};
+        let default = AgentSettings::get_global(cx).new_thread_location;
+        let start_thread_in = match default {
+            NewThreadLocation::LocalProject => StartThreadIn::LocalProject,
+            NewThreadLocation::NewWorktree => StartThreadIn::NewWorktree,
+        };
+        if self.start_thread_in != start_thread_in {
+            self.start_thread_in = start_thread_in;
+            self.serialize(cx);
+            cx.notify();
+        }
+    }
+
     fn selected_external_agent(&self) -> Option<Agent> {
         match &self.selected_agent {
             AgentType::NativeAgent => Some(Agent::NativeAgent),
@@ -2445,6 +2453,7 @@ impl AgentPanel {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
+        self.reset_start_thread_in_to_default(cx);
         self.new_agent_thread_inner(agent, true, window, cx);
     }
 
@@ -3592,9 +3601,12 @@ impl AgentPanel {
     }
 
     fn render_start_thread_in_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
+        use settings::{NewThreadLocation, Settings};
+
         let focus_handle = self.focus_handle(cx);
         let has_git_repo = self.project_has_git_repository(cx);
         let is_via_collab = self.project.read(cx).is_via_collab();
+        let fs = self.fs.clone();
 
         let is_creating = matches!(
             self.worktree_creation_status,
@@ -3604,6 +3616,10 @@ impl AgentPanel {
         let current_target = self.start_thread_in;
         let trigger_label = self.start_thread_in.label();
 
+        let new_thread_location = AgentSettings::get_global(cx).new_thread_location;
+        let is_local_default = new_thread_location == NewThreadLocation::LocalProject;
+        let is_new_worktree_default = new_thread_location == NewThreadLocation::NewWorktree;
+
         let icon = if self.start_thread_in_menu_handle.is_deployed() {
             IconName::ChevronUp
         } else {
@@ -3631,7 +3647,7 @@ impl AgentPanel {
                 move |_window, cx| {
                     Tooltip::for_action_in(
                         "Start Thread In…",
-                        &ToggleStartThreadInSelector,
+                        &CycleStartThreadIn,
                         &focus_handle,
                         cx,
                     )
@@ -3640,6 +3656,7 @@ impl AgentPanel {
             .menu(move |window, cx| {
                 let is_local_selected = current_target == StartThreadIn::LocalProject;
                 let is_new_worktree_selected = current_target == StartThreadIn::NewWorktree;
+                let fs = fs.clone();
 
                 Some(ContextMenu::build(window, cx, move |menu, _window, _cx| {
                     let new_worktree_disabled = !has_git_repo || is_via_collab;
@@ -3648,18 +3665,53 @@ impl AgentPanel {
                         .item(
                             ContextMenuEntry::new("Current Project")
                                 .toggleable(IconPosition::End, is_local_selected)
-                                .handler(|window, cx| {
-                                    window
-                                        .dispatch_action(Box::new(StartThreadIn::LocalProject), cx);
+                                .documentation_aside(documentation_side, move |_| {
+                                    HoldForDefault::new(is_local_default)
+                                        .more_content(false)
+                                        .into_any_element()
+                                })
+                                .handler({
+                                    let fs = fs.clone();
+                                    move |window, cx| {
+                                        if window.modifiers().secondary() {
+                                            update_settings_file(fs.clone(), cx, |settings, _| {
+                                                settings
+                                                    .agent
+                                                    .get_or_insert_default()
+                                                    .set_new_thread_location(
+                                                        NewThreadLocation::LocalProject,
+                                                    );
+                                            });
+                                        }
+                                        window.dispatch_action(
+                                            Box::new(StartThreadIn::LocalProject),
+                                            cx,
+                                        );
+                                    }
                                 }),
                         )
                         .item({
                             let entry = ContextMenuEntry::new("New Worktree")
                                 .toggleable(IconPosition::End, is_new_worktree_selected)
                                 .disabled(new_worktree_disabled)
-                                .handler(|window, cx| {
-                                    window
-                                        .dispatch_action(Box::new(StartThreadIn::NewWorktree), cx);
+                                .handler({
+                                    let fs = fs.clone();
+                                    move |window, cx| {
+                                        if window.modifiers().secondary() {
+                                            update_settings_file(fs.clone(), cx, |settings, _| {
+                                                settings
+                                                    .agent
+                                                    .get_or_insert_default()
+                                                    .set_new_thread_location(
+                                                        NewThreadLocation::NewWorktree,
+                                                    );
+                                            });
+                                        }
+                                        window.dispatch_action(
+                                            Box::new(StartThreadIn::NewWorktree),
+                                            cx,
+                                        );
+                                    }
                                 });
 
                             if new_worktree_disabled {
@@ -3675,7 +3727,11 @@ impl AgentPanel {
                                         .into_any_element()
                                 })
                             } else {
-                                entry
+                                entry.documentation_aside(documentation_side, move |_| {
+                                    HoldForDefault::new(is_new_worktree_default)
+                                        .more_content(false)
+                                        .into_any_element()
+                                })
                             }
                         })
                 }))
@@ -4849,7 +4905,6 @@ impl Render for AgentPanel {
             .on_action(cx.listener(Self::go_back))
             .on_action(cx.listener(Self::toggle_navigation_menu))
             .on_action(cx.listener(Self::toggle_options_menu))
-            .on_action(cx.listener(Self::toggle_start_thread_in_selector))
             .on_action(cx.listener(Self::increase_font_size))
             .on_action(cx.listener(Self::decrease_font_size))
             .on_action(cx.listener(Self::reset_font_size))

crates/agent_ui/src/agent_ui.rs πŸ”—

@@ -86,8 +86,8 @@ actions!(
         NewTextThread,
         /// Toggles the menu to create new agent threads.
         ToggleNewThreadMenu,
-        /// Toggles the selector for choosing where new threads start (current project or new worktree).
-        ToggleStartThreadInSelector,
+        /// Cycles through the options for where new threads start (current project or new worktree).
+        CycleStartThreadIn,
         /// Toggles the navigation menu for switching between threads and views.
         ToggleNavigationMenu,
         /// Toggles the options menu for agent settings and preferences.
@@ -655,6 +655,7 @@ mod tests {
             message_editor_min_lines: 1,
             tool_permissions: Default::default(),
             show_turn_stats: false,
+            new_thread_location: Default::default(),
         };
 
         cx.update(|cx| {

crates/agent_ui/src/ui/hold_for_default.rs πŸ”—

@@ -4,20 +4,31 @@ use ui::{prelude::*, render_modifiers};
 #[derive(IntoElement)]
 pub struct HoldForDefault {
     is_default: bool,
+    more_content: bool,
 }
 
 impl HoldForDefault {
     pub fn new(is_default: bool) -> Self {
-        Self { is_default }
+        Self {
+            is_default,
+            more_content: true,
+        }
+    }
+
+    pub fn more_content(mut self, more_content: bool) -> Self {
+        self.more_content = more_content;
+        self
     }
 }
 
 impl RenderOnce for HoldForDefault {
     fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
         h_flex()
-            .pt_1()
-            .border_t_1()
-            .border_color(cx.theme().colors().border_variant)
+            .when(self.more_content, |this| {
+                this.pt_1()
+                    .border_t_1()
+                    .border_color(cx.theme().colors().border_variant)
+            })
             .gap_0p5()
             .text_sm()
             .text_color(Color::Muted.color(cx))

crates/settings_content/src/agent.rs πŸ”—

@@ -9,6 +9,19 @@ use crate::ExtendingVec;
 
 use crate::DockPosition;
 
+/// Where new threads should start by default.
+#[derive(
+    Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom,
+)]
+#[serde(rename_all = "snake_case")]
+pub enum NewThreadLocation {
+    /// Start threads in the current project.
+    #[default]
+    LocalProject,
+    /// Start threads in a new worktree.
+    NewWorktree,
+}
+
 #[with_fallible_options]
 #[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, Default)]
 pub struct AgentSettingsContent {
@@ -59,6 +72,10 @@ pub struct AgentSettingsContent {
     ///
     /// Default: "thread"
     pub default_view: Option<DefaultAgentView>,
+    /// Where new threads should start by default.
+    ///
+    /// Default: "local_project"
+    pub new_thread_location: Option<NewThreadLocation>,
     /// The available agent profiles.
     pub profiles: Option<IndexMap<Arc<str>, AgentProfileContent>>,
     /// Where to show a popup notification when the agent is waiting for user input.
@@ -146,6 +163,10 @@ impl AgentSettingsContent {
         self.default_profile = Some(profile_id);
     }
 
+    pub fn set_new_thread_location(&mut self, value: NewThreadLocation) {
+        self.new_thread_location = Some(value);
+    }
+
     pub fn add_favorite_model(&mut self, model: LanguageModelSelection) {
         if !self.favorite_models.contains(&model) {
             self.favorite_models.push(model);

crates/settings_ui/src/page_data.rs πŸ”—

@@ -6972,7 +6972,7 @@ fn ai_page() -> SettingsPage {
         ]
     }
 
-    fn agent_configuration_section() -> [SettingsPageItem; 12] {
+    fn agent_configuration_section() -> [SettingsPageItem; 13] {
         [
             SettingsPageItem::SectionHeader("Agent Configuration"),
             SettingsPageItem::SubPageLink(SubPageLink {
@@ -6984,6 +6984,28 @@ fn ai_page() -> SettingsPage {
                 files: USER,
                 render: render_tool_permissions_setup_page,
             }),
+            SettingsPageItem::SettingItem(SettingItem {
+                title: "New Thread Location",
+                description: "Whether to start a new thread in the current local project or in a new Git worktree.",
+                field: Box::new(SettingField {
+                    json_path: Some("agent.default_start_thread_in"),
+                    pick: |settings_content| {
+                        settings_content
+                            .agent
+                            .as_ref()?
+                            .new_thread_location
+                            .as_ref()
+                    },
+                    write: |settings_content, value| {
+                        settings_content
+                            .agent
+                            .get_or_insert_default()
+                            .new_thread_location = value;
+                    },
+                }),
+                metadata: None,
+                files: USER,
+            }),
             SettingsPageItem::SettingItem(SettingItem {
                 title: "Single File Review",
                 description: "When enabled, agent edits will also be displayed in single-file buffers for review.",