Add "Start Thread in New Worktree" (#49141)

Richard Feldman , Oleksiy Syvokon , Ben Brandt , Anthony Eid , Remco Smits , morgankrey , Danilo Leal , Ben Kunkle , Finn Evers , Bennet Bo Fenner , Zed Zippy , MostlyK , cameron , Max Brunsfeld , John Tur , Conrad Irwin , Wuji Chen , Claude , Smit Barmase , Cole Miller , Kasper Nyhus , dino , Anthony Eid , Josh Robson Chase , ozacod , ozacod , Xiaobo Liu , Lena , 0x2CA , Joseph T. Lyons , Albab Hasan , KyleBarton , Kunall Banerjee , Lukas Wirth , Tom Houlé , Nikhil Pandey , Mikayla Maki , dancer , Kirill Bulatov , and Danilo Leal created

Add the thread target selector in the agent panel behind the
`agent-git-worktrees` flag:

<img width="590" height="121" alt="Screenshot 2026-03-02 at 11 50 47 PM"
src="https://github.com/user-attachments/assets/17ee3303-7e01-4e40-bb84-1e7e748a3196"
/>

- Add a "Start Thread In..." dropdown to the agent panel toolbar, gated
behind `AgentV2FeatureFlag`
- Options: "Local Project" (default) and "New Worktree"
- The "New Worktree" option is disabled when there's no git repository
or in collab mode

Closes AI-34

Release Notes:

- N/A

---------

Signed-off-by: Xiaobo Liu <cppcoffee@gmail.com>
Co-authored-by: Oleksiy Syvokon <oleksiy@zed.dev>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
Co-authored-by: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com>
Co-authored-by: Remco Smits <djsmits12@gmail.com>
Co-authored-by: morgankrey <morgan@zed.dev>
Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
Co-authored-by: Ben Kunkle <ben@zed.dev>
Co-authored-by: Finn Evers <finn@zed.dev>
Co-authored-by: Bennet Bo Fenner <bennet@zed.dev>
Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
Co-authored-by: MostlyK <135974627+MostlyKIGuess@users.noreply.github.com>
Co-authored-by: cameron <cameron.studdstreet@gmail.com>
Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
Co-authored-by: John Tur <john-tur@outlook.com>
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
Co-authored-by: Wuji Chen <chenwuji2000@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
Co-authored-by: Cole Miller <cole@zed.dev>
Co-authored-by: Kasper Nyhus <kanyhus@gmail.com>
Co-authored-by: dino <dinojoaocosta@gmail.com>
Co-authored-by: Anthony Eid <hello@anthonyeid.me>
Co-authored-by: Josh Robson Chase <josh@robsonchase.com>
Co-authored-by: ozacod <47009516+ozacod@users.noreply.github.com>
Co-authored-by: ozacod <ozacod@users.noreply.github.com>
Co-authored-by: Xiaobo Liu <cppcoffee@gmail.com>
Co-authored-by: Lena <241371603+zelenenka@users.noreply.github.com>
Co-authored-by: 0x2CA <2478557459@qq.com>
Co-authored-by: Joseph T. Lyons <JosephTLyons@gmail.com>
Co-authored-by: Albab Hasan <155961300+Albab-Hasan@users.noreply.github.com>
Co-authored-by: KyleBarton <kjb@initialcapacity.io>
Co-authored-by: Kunall Banerjee <hey@kimchiii.space>
Co-authored-by: Lukas Wirth <lukas@zed.dev>
Co-authored-by: Tom Houlé <13155277+tomhoule@users.noreply.github.com>
Co-authored-by: Nikhil Pandey <nikhil@nikhil.com.np>
Co-authored-by: Mikayla Maki <mikayla@zed.dev>
Co-authored-by: dancer <144584931+dancer@users.noreply.github.com>
Co-authored-by: Kirill Bulatov <kirill@zed.dev>
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>

Change summary

Cargo.lock                                         |   1 
crates/agent_ui/Cargo.toml                         |   1 
crates/agent_ui/src/agent_panel.rs                 | 916 +++++++++++++--
crates/agent_ui/src/agent_ui.rs                    |  16 
crates/agent_ui/src/connection_view.rs             |  16 
crates/agent_ui/src/connection_view/thread_view.rs |  93 +
crates/collab/src/db/queries/projects.rs           |   3 
crates/collab/src/db/queries/rooms.rs              |   3 
crates/feature_flags/src/flags.rs                  |  10 
crates/git/src/repository.rs                       |  48 
crates/git_ui/src/worktree_picker.rs               |   4 
crates/project/src/git_store.rs                    |  71 +
crates/proto/proto/git.proto                       |   1 
crates/workspace/src/persistence/model.rs          |  12 
crates/workspace/src/workspace.rs                  | 171 ++
crates/zed/src/visual_test_runner.rs               | 649 +++++++++++
crates/zed/src/zed.rs                              |  28 
17 files changed, 1,792 insertions(+), 251 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -368,6 +368,7 @@ dependencies = [
  "fs",
  "futures 0.3.31",
  "fuzzy",
+ "git",
  "gpui",
  "gpui_tokio",
  "html_to_markdown",

crates/agent_ui/Cargo.toml 🔗

@@ -58,6 +58,7 @@ feature_flags.workspace = true
 file_icons.workspace = true
 fs.workspace = true
 futures.workspace = true
+git.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true
 gpui_tokio.workspace = true

crates/agent_ui/src/agent_panel.rs 🔗

@@ -1,6 +1,6 @@
 use std::{
     ops::Range,
-    path::Path,
+    path::{Path, PathBuf},
     rc::Rc,
     sync::{
         Arc,
@@ -22,15 +22,18 @@ use project::{
 use serde::{Deserialize, Serialize};
 use settings::{LanguageModelProviderSetting, LanguageModelSelection};
 
+use feature_flags::{AgentGitWorktreesFeatureFlag, AgentV2FeatureFlag, FeatureFlagAppExt as _};
 use zed_actions::agent::{OpenClaudeAgentOnboardingModal, ReauthenticateAgent, ReviewBranchDiff};
 
+use crate::ManageProfiles;
 use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal};
 use crate::{
     AddContextServer, AgentDiffPane, ConnectionView, CopyThreadToClipboard, Follow,
     InlineAssistant, LoadThreadFromClipboard, NewTextThread, NewThread, OpenActiveThreadAsMarkdown,
-    OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, ToggleNavigationMenu,
-    ToggleNewThreadMenu, ToggleOptionsMenu,
+    OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, StartThreadIn,
+    ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu,
     agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
+    connection_view::{AcpThreadViewEvent, ThreadView},
     slash_command::SlashCommandCompletionProvider,
     text_thread_editor::{AgentPanelDelegate, TextThreadEditor, make_lsp_adapter_delegate},
     ui::EndTrialUpsell,
@@ -42,7 +45,6 @@ use crate::{
     ExpandMessageEditor, ThreadHistory, ThreadHistoryEvent,
     text_thread_history::{TextThreadHistory, TextThreadHistoryEvent},
 };
-use crate::{ManageProfiles, connection_view::ThreadView};
 use agent_settings::AgentSettings;
 use ai_onboarding::AgentPanelOnboarding;
 use anyhow::{Result, anyhow};
@@ -54,6 +56,7 @@ use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer};
 use extension::ExtensionEvents;
 use extension_host::ExtensionStore;
 use fs::Fs;
+use git::repository::validate_worktree_directory;
 use gpui::{
     Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner,
     DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels,
@@ -61,15 +64,17 @@ use gpui::{
 };
 use language::LanguageRegistry;
 use language_model::{ConfigurationError, LanguageModelRegistry};
+use project::project_settings::ProjectSettings;
 use project::{Project, ProjectPath, Worktree};
 use prompt_store::{PromptBuilder, PromptStore, UserPromptId};
+use rand::Rng as _;
 use rules_library::{RulesLibrary, open_rules_library};
 use search::{BufferSearchBar, buffer_search};
 use settings::{Settings, update_settings_file};
 use theme::ThemeSettings;
 use ui::{
-    Callout, ContextMenu, ContextMenuEntry, KeyBinding, PopoverMenu, PopoverMenuHandle, Tab,
-    Tooltip, prelude::*, utils::WithRemSize,
+    Button, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding, PopoverMenu,
+    PopoverMenuHandle, SpinnerLabel, Tab, Tooltip, prelude::*, utils::WithRemSize,
 };
 use util::ResultExt as _;
 use workspace::{
@@ -123,6 +128,8 @@ struct SerializedAgentPanel {
     selected_agent: Option<AgentType>,
     #[serde(default)]
     last_active_thread: Option<SerializedActiveThread>,
+    #[serde(default)]
+    start_thread_in: Option<StartThreadIn>,
 }
 
 #[derive(Serialize, Deserialize, Debug, Clone)]
@@ -324,6 +331,13 @@ pub fn init(cx: &mut App) {
                             cx,
                         );
                     });
+                })
+                .register_action(|workspace, action: &StartThreadIn, _window, cx| {
+                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+                        panel.update(cx, |panel, cx| {
+                            panel.set_start_thread_in(action, cx);
+                        });
+                    }
                 });
         },
     )
@@ -371,6 +385,10 @@ pub enum AgentType {
 }
 
 impl AgentType {
+    pub fn is_native(&self) -> bool {
+        matches!(self, Self::NativeAgent)
+    }
+
     fn label(&self) -> SharedString {
         match self {
             Self::NativeAgent | Self::TextThread => "Zed Agent".into(),
@@ -395,6 +413,29 @@ impl From<ExternalAgent> for AgentType {
     }
 }
 
+impl StartThreadIn {
+    fn label(&self) -> SharedString {
+        match self {
+            Self::LocalProject => "Local Project".into(),
+            Self::NewWorktree => "New Worktree".into(),
+        }
+    }
+
+    fn icon(&self) -> IconName {
+        match self {
+            Self::LocalProject => IconName::Screen,
+            Self::NewWorktree => IconName::GitBranchPlus,
+        }
+    }
+}
+
+#[derive(Clone, Debug)]
+#[allow(dead_code)]
+pub enum WorktreeCreationStatus {
+    Creating,
+    Error(SharedString),
+}
+
 impl ActiveView {
     pub fn which_font_size_used(&self) -> WhichFontSize {
         match self {
@@ -515,6 +556,7 @@ pub struct AgentPanel {
     previous_view: Option<ActiveView>,
     _active_view_observation: Option<Subscription>,
     new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
+    start_thread_in_menu_handle: PopoverMenuHandle<ContextMenu>,
     agent_panel_menu_handle: PopoverMenuHandle<ContextMenu>,
     agent_navigation_menu_handle: PopoverMenuHandle<ContextMenu>,
     agent_navigation_menu: Option<Entity<ContextMenu>>,
@@ -525,6 +567,10 @@ pub struct AgentPanel {
     pending_serialization: Option<Task<Result<()>>>,
     onboarding: Entity<AgentPanelOnboarding>,
     selected_agent: AgentType,
+    start_thread_in: StartThreadIn,
+    worktree_creation_status: Option<WorktreeCreationStatus>,
+    _thread_view_subscription: Option<Subscription>,
+    _worktree_creation_task: Option<Task<()>>,
     show_trust_workspace_message: bool,
     last_configuration_error_telemetry: Option<String>,
     on_boarding_upsell_dismissed: AtomicBool,
@@ -538,6 +584,7 @@ impl AgentPanel {
 
         let width = self.width;
         let selected_agent = self.selected_agent.clone();
+        let start_thread_in = Some(self.start_thread_in);
 
         let last_active_thread = self.active_agent_thread(cx).map(|thread| {
             let thread = thread.read(cx);
@@ -561,6 +608,7 @@ impl AgentPanel {
                     width,
                     selected_agent: Some(selected_agent),
                     last_active_thread,
+                    start_thread_in,
                 },
             )
             .await?;
@@ -605,6 +653,37 @@ impl AgentPanel {
                 })?
                 .await?;
 
+            let last_active_thread = if let Some(thread_info) = serialized_panel
+                .as_ref()
+                .and_then(|p| p.last_active_thread.clone())
+            {
+                if thread_info.agent_type.is_native() {
+                    let session_id = acp::SessionId::new(thread_info.session_id.clone());
+                    let load_result = cx.update(|_window, cx| {
+                        let thread_store = ThreadStore::global(cx);
+                        thread_store.update(cx, |store, cx| store.load_thread(session_id, cx))
+                    });
+                    let thread_exists = if let Ok(task) = load_result {
+                        task.await.ok().flatten().is_some()
+                    } else {
+                        false
+                    };
+                    if thread_exists {
+                        Some(thread_info)
+                    } else {
+                        log::warn!(
+                            "last active thread {} not found in database, skipping restoration",
+                            thread_info.session_id
+                        );
+                        None
+                    }
+                } else {
+                    Some(thread_info)
+                }
+            } else {
+                None
+            };
+
             let panel = workspace.update_in(cx, |workspace, window, cx| {
                 let panel =
                     cx.new(|cx| Self::new(workspace, text_thread_store, prompt_store, window, cx));
@@ -615,44 +694,45 @@ impl AgentPanel {
                         if let Some(selected_agent) = serialized_panel.selected_agent.clone() {
                             panel.selected_agent = selected_agent;
                         }
+                        if let Some(start_thread_in) = serialized_panel.start_thread_in {
+                            let is_worktree_flag_enabled =
+                                cx.has_flag::<AgentGitWorktreesFeatureFlag>();
+                            let is_valid = match &start_thread_in {
+                                StartThreadIn::LocalProject => true,
+                                StartThreadIn::NewWorktree => {
+                                    let project = panel.project.read(cx);
+                                    is_worktree_flag_enabled && !project.is_via_collab()
+                                }
+                            };
+                            if is_valid {
+                                panel.start_thread_in = start_thread_in;
+                            } else {
+                                log::info!(
+                                    "deserialized start_thread_in {:?} is no longer valid, falling back to LocalProject",
+                                    start_thread_in,
+                                );
+                            }
+                        }
                         cx.notify();
                     });
                 }
 
-                panel
-            })?;
-
-            if let Some(thread_info) = serialized_panel.and_then(|p| p.last_active_thread) {
-                let session_id = acp::SessionId::new(thread_info.session_id.clone());
-                let load_task = panel.update(cx, |panel, cx| {
-                    let thread_store = panel.thread_store.clone();
-                    thread_store.update(cx, |store, cx| store.load_thread(session_id, cx))
-                });
-                let thread_exists = load_task
-                    .await
-                    .map(|thread: Option<agent::DbThread>| thread.is_some())
-                    .unwrap_or(false);
-
-                if thread_exists {
-                    panel.update_in(cx, |panel, window, cx| {
-                        panel.selected_agent = thread_info.agent_type.clone();
-                        let session_info = AgentSessionInfo {
-                            session_id: acp::SessionId::new(thread_info.session_id),
-                            cwd: thread_info.cwd,
-                            title: thread_info.title.map(SharedString::from),
-                            updated_at: None,
-                            meta: None,
-                        };
+                if let Some(thread_info) = last_active_thread {
+                    let agent_type = thread_info.agent_type.clone();
+                    let session_info = AgentSessionInfo {
+                        session_id: acp::SessionId::new(thread_info.session_id),
+                        cwd: thread_info.cwd,
+                        title: thread_info.title.map(SharedString::from),
+                        updated_at: None,
+                        meta: None,
+                    };
+                    panel.update(cx, |panel, cx| {
+                        panel.selected_agent = agent_type;
                         panel.load_agent_thread(session_info, window, cx);
-                    })?;
-                } else {
-                    log::error!(
-                        "could not restore last active thread: \
-                         no thread found in database with ID {:?}",
-                        thread_info.session_id
-                    );
+                    });
                 }
-            }
+                panel
+            })?;
 
             Ok(panel)
         })
@@ -800,6 +880,7 @@ impl AgentPanel {
             previous_view: None,
             _active_view_observation: None,
             new_thread_menu_handle: PopoverMenuHandle::default(),
+            start_thread_in_menu_handle: PopoverMenuHandle::default(),
             agent_panel_menu_handle: PopoverMenuHandle::default(),
             agent_navigation_menu_handle: PopoverMenuHandle::default(),
             agent_navigation_menu: None,
@@ -813,6 +894,10 @@ impl AgentPanel {
             text_thread_history,
             thread_store,
             selected_agent: AgentType::default(),
+            start_thread_in: StartThreadIn::default(),
+            worktree_creation_status: None,
+            _thread_view_subscription: None,
+            _worktree_creation_task: None,
             show_trust_workspace_message: false,
             last_configuration_error_telemetry: None,
             on_boarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed()),
@@ -1044,7 +1129,7 @@ impl AgentPanel {
 
             let server = ext_agent.server(fs, thread_store);
             this.update_in(cx, |agent_panel, window, cx| {
-                agent_panel._external_thread(
+                agent_panel.create_external_thread(
                     server,
                     resume_thread,
                     initial_content,
@@ -1618,15 +1703,28 @@ impl AgentPanel {
             self.active_view = new_view;
         }
 
+        // Subscribe to the active ThreadView's events (e.g. FirstSendRequested)
+        // so the panel can intercept the first send for worktree creation.
+        // Re-subscribe whenever the ConnectionView changes, since the inner
+        // ThreadView may have been replaced (e.g. navigating between threads).
         self._active_view_observation = match &self.active_view {
             ActiveView::AgentThread { server_view } => {
-                Some(cx.observe(server_view, |this, _, cx| {
-                    cx.emit(AgentPanelEvent::ActiveViewChanged);
-                    this.serialize(cx);
-                    cx.notify();
-                }))
+                self._thread_view_subscription =
+                    Self::subscribe_to_active_thread_view(server_view, window, cx);
+                Some(
+                    cx.observe_in(server_view, window, |this, server_view, window, cx| {
+                        this._thread_view_subscription =
+                            Self::subscribe_to_active_thread_view(&server_view, window, cx);
+                        cx.emit(AgentPanelEvent::ActiveViewChanged);
+                        this.serialize(cx);
+                        cx.notify();
+                    }),
+                )
+            }
+            _ => {
+                self._thread_view_subscription = None;
+                None
             }
-            _ => None,
         };
 
         let is_in_agent_history = matches!(
@@ -1740,6 +1838,56 @@ impl AgentPanel {
         self.selected_agent.clone()
     }
 
+    fn subscribe_to_active_thread_view(
+        server_view: &Entity<ConnectionView>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<Subscription> {
+        server_view.read(cx).active_thread().cloned().map(|tv| {
+            cx.subscribe_in(
+                &tv,
+                window,
+                |this, view, event: &AcpThreadViewEvent, window, cx| match event {
+                    AcpThreadViewEvent::FirstSendRequested { content } => {
+                        this.handle_first_send_requested(view.clone(), content.clone(), window, cx);
+                    }
+                },
+            )
+        })
+    }
+
+    pub fn start_thread_in(&self) -> &StartThreadIn {
+        &self.start_thread_in
+    }
+
+    fn set_start_thread_in(&mut self, action: &StartThreadIn, cx: &mut Context<Self>) {
+        if matches!(action, StartThreadIn::NewWorktree)
+            && !cx.has_flag::<AgentGitWorktreesFeatureFlag>()
+        {
+            return;
+        }
+
+        let new_target = match *action {
+            StartThreadIn::LocalProject => StartThreadIn::LocalProject,
+            StartThreadIn::NewWorktree => {
+                if !self.project_has_git_repository(cx) {
+                    log::error!(
+                        "set_start_thread_in: cannot use NewWorktree without a git repository"
+                    );
+                    return;
+                }
+                if self.project.read(cx).is_via_collab() {
+                    log::error!("set_start_thread_in: cannot use NewWorktree in a collab project");
+                    return;
+                }
+                StartThreadIn::NewWorktree
+            }
+        };
+        self.start_thread_in = new_target;
+        self.serialize(cx);
+        cx.notify();
+    }
+
     fn selected_external_agent(&self) -> Option<ExternalAgent> {
         match &self.selected_agent {
             AgentType::NativeAgent => Some(ExternalAgent::NativeAgent),
@@ -1830,7 +1978,7 @@ impl AgentPanel {
         self.external_thread(Some(agent), Some(thread), None, window, cx);
     }
 
-    fn _external_thread(
+    pub(crate) fn create_external_thread(
         &mut self,
         server: Rc<dyn AgentServer>,
         resume_thread: Option<AgentSessionInfo>,
@@ -1869,140 +2017,616 @@ impl AgentPanel {
 
         self.set_active_view(ActiveView::AgentThread { server_view }, true, window, cx);
     }
-}
 
-impl Focusable for AgentPanel {
-    fn focus_handle(&self, cx: &App) -> FocusHandle {
-        match &self.active_view {
-            ActiveView::Uninitialized => self.focus_handle.clone(),
-            ActiveView::AgentThread { server_view, .. } => server_view.focus_handle(cx),
-            ActiveView::History { kind } => match kind {
-                HistoryKind::AgentThreads => self.acp_history.focus_handle(cx),
-                HistoryKind::TextThreads => self.text_thread_history.focus_handle(cx),
-            },
-            ActiveView::TextThread {
-                text_thread_editor, ..
-            } => text_thread_editor.focus_handle(cx),
-            ActiveView::Configuration => {
-                if let Some(configuration) = self.configuration.as_ref() {
-                    configuration.focus_handle(cx)
-                } else {
-                    self.focus_handle.clone()
-                }
-            }
+    fn active_thread_has_messages(&self, cx: &App) -> bool {
+        self.active_agent_thread(cx)
+            .is_some_and(|thread| !thread.read(cx).entries().is_empty())
+    }
+
+    fn handle_first_send_requested(
+        &mut self,
+        thread_view: Entity<ThreadView>,
+        content: Vec<acp::ContentBlock>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if self.start_thread_in == StartThreadIn::NewWorktree {
+            self.handle_worktree_creation_requested(content, window, cx);
+        } else {
+            cx.defer_in(window, move |_this, window, cx| {
+                thread_view.update(cx, |thread_view, cx| {
+                    let editor = thread_view.message_editor.clone();
+                    thread_view.send_impl(editor, window, cx);
+                });
+            });
         }
     }
-}
 
-fn agent_panel_dock_position(cx: &App) -> DockPosition {
-    AgentSettings::get_global(cx).dock.into()
-}
+    fn generate_agent_branch_name() -> String {
+        let mut rng = rand::rng();
+        let id: String = (0..8)
+            .map(|_| {
+                let idx: u8 = rng.random_range(0..36);
+                if idx < 10 {
+                    (b'0' + idx) as char
+                } else {
+                    (b'a' + idx - 10) as char
+                }
+            })
+            .collect();
+        format!("agent-{id}")
+    }
 
-pub enum AgentPanelEvent {
-    ActiveViewChanged,
-}
+    /// Partitions the project's visible worktrees into git-backed repositories
+    /// and plain (non-git) paths. Git repos will have worktrees created for
+    /// them; non-git paths are carried over to the new workspace as-is.
+    ///
+    /// When multiple worktrees map to the same repository, the most specific
+    /// match wins (deepest work directory path), with a deterministic
+    /// tie-break on entity id. Each repository appears at most once.
+    fn classify_worktrees(
+        &self,
+        cx: &App,
+    ) -> (Vec<Entity<project::git_store::Repository>>, Vec<PathBuf>) {
+        let project = &self.project;
+        let repositories = project.read(cx).repositories(cx).clone();
+        let mut git_repos: Vec<Entity<project::git_store::Repository>> = Vec::new();
+        let mut non_git_paths: Vec<PathBuf> = Vec::new();
+        let mut seen_repo_ids = std::collections::HashSet::new();
+
+        for worktree in project.read(cx).visible_worktrees(cx) {
+            let wt_path = worktree.read(cx).abs_path();
+
+            let matching_repo = repositories
+                .iter()
+                .filter_map(|(id, repo)| {
+                    let work_dir = repo.read(cx).work_directory_abs_path.clone();
+                    if wt_path.starts_with(work_dir.as_ref())
+                        || work_dir.starts_with(wt_path.as_ref())
+                    {
+                        Some((*id, repo.clone(), work_dir.as_ref().components().count()))
+                    } else {
+                        None
+                    }
+                })
+                .max_by(
+                    |(left_id, _left_repo, left_depth), (right_id, _right_repo, right_depth)| {
+                        left_depth
+                            .cmp(right_depth)
+                            .then_with(|| left_id.cmp(right_id))
+                    },
+                );
 
-impl EventEmitter<PanelEvent> for AgentPanel {}
-impl EventEmitter<AgentPanelEvent> for AgentPanel {}
+            if let Some((id, repo, _)) = matching_repo {
+                if seen_repo_ids.insert(id) {
+                    git_repos.push(repo);
+                }
+            } else {
+                non_git_paths.push(wt_path.to_path_buf());
+            }
+        }
 
-impl Panel for AgentPanel {
-    fn persistent_name() -> &'static str {
-        "AgentPanel"
+        (git_repos, non_git_paths)
     }
 
-    fn panel_key() -> &'static str {
-        AGENT_PANEL_KEY
-    }
+    /// Kicks off an async git-worktree creation for each repository. Returns:
+    ///
+    /// - `creation_infos`: a vec of `(repo, new_path, receiver)` tuples—the
+    ///   receiver resolves once the git worktree command finishes.
+    /// - `path_remapping`: `(old_work_dir, new_worktree_path)` pairs used
+    ///   later to remap open editor tabs into the new workspace.
+    fn start_worktree_creations(
+        git_repos: &[Entity<project::git_store::Repository>],
+        branch_name: &str,
+        worktree_directory_setting: &str,
+        cx: &mut Context<Self>,
+    ) -> Result<(
+        Vec<(
+            Entity<project::git_store::Repository>,
+            PathBuf,
+            futures::channel::oneshot::Receiver<Result<()>>,
+        )>,
+        Vec<(PathBuf, PathBuf)>,
+    )> {
+        let mut creation_infos = Vec::new();
+        let mut path_remapping = Vec::new();
+
+        for repo in git_repos {
+            let (work_dir, new_path, receiver) = repo.update(cx, |repo, _cx| {
+                let original_repo = repo.original_repo_abs_path.clone();
+                let directory =
+                    validate_worktree_directory(&original_repo, worktree_directory_setting)?;
+                let new_path = directory.join(branch_name);
+                let receiver = repo.create_worktree(branch_name.to_string(), directory, None);
+                let work_dir = repo.work_directory_abs_path.clone();
+                anyhow::Ok((work_dir, new_path, receiver))
+            })?;
+            path_remapping.push((work_dir.to_path_buf(), new_path.clone()));
+            creation_infos.push((repo.clone(), new_path, receiver));
+        }
 
-    fn position(&self, _window: &Window, cx: &App) -> DockPosition {
-        agent_panel_dock_position(cx)
+        Ok((creation_infos, path_remapping))
     }
 
-    fn position_is_valid(&self, position: DockPosition) -> bool {
-        position != DockPosition::Bottom
-    }
+    /// Waits for every in-flight worktree creation to complete. If any
+    /// creation fails, all successfully-created worktrees are rolled back
+    /// (removed) so the project isn't left in a half-migrated state.
+    async fn await_and_rollback_on_failure(
+        creation_infos: Vec<(
+            Entity<project::git_store::Repository>,
+            PathBuf,
+            futures::channel::oneshot::Receiver<Result<()>>,
+        )>,
+        cx: &mut AsyncWindowContext,
+    ) -> Result<Vec<PathBuf>> {
+        let mut created_paths: Vec<PathBuf> = Vec::new();
+        let mut repos_and_paths: Vec<(Entity<project::git_store::Repository>, PathBuf)> =
+            Vec::new();
+        let mut first_error: Option<anyhow::Error> = None;
+
+        for (repo, new_path, receiver) in creation_infos {
+            match receiver.await {
+                Ok(Ok(())) => {
+                    created_paths.push(new_path.clone());
+                    repos_and_paths.push((repo, new_path));
+                }
+                Ok(Err(err)) => {
+                    if first_error.is_none() {
+                        first_error = Some(err);
+                    }
+                }
+                Err(_canceled) => {
+                    if first_error.is_none() {
+                        first_error = Some(anyhow!("Worktree creation was canceled"));
+                    }
+                }
+            }
+        }
 
-    fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
-        settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
-            settings
-                .agent
-                .get_or_insert_default()
-                .set_dock(position.into());
-        });
-    }
+        let Some(err) = first_error else {
+            return Ok(created_paths);
+        };
 
-    fn size(&self, window: &Window, cx: &App) -> Pixels {
-        let settings = AgentSettings::get_global(cx);
-        match self.position(window, cx) {
-            DockPosition::Left | DockPosition::Right => {
-                self.width.unwrap_or(settings.default_width)
+        // Rollback all successfully created worktrees
+        let mut rollback_receivers = Vec::new();
+        for (rollback_repo, rollback_path) in &repos_and_paths {
+            if let Ok(receiver) = cx.update(|_, cx| {
+                rollback_repo.update(cx, |repo, _cx| {
+                    repo.remove_worktree(rollback_path.clone(), true)
+                })
+            }) {
+                rollback_receivers.push((rollback_path.clone(), receiver));
             }
-            DockPosition::Bottom => self.height.unwrap_or(settings.default_height),
         }
-    }
-
-    fn set_size(&mut self, size: Option<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
-        match self.position(window, cx) {
-            DockPosition::Left | DockPosition::Right => self.width = size,
-            DockPosition::Bottom => self.height = size,
+        let mut rollback_failures: Vec<String> = Vec::new();
+        for (path, receiver) in rollback_receivers {
+            match receiver.await {
+                Ok(Ok(())) => {}
+                Ok(Err(rollback_err)) => {
+                    log::error!(
+                        "failed to rollback worktree at {}: {rollback_err}",
+                        path.display()
+                    );
+                    rollback_failures.push(format!("{}: {rollback_err}", path.display()));
+                }
+                Err(rollback_err) => {
+                    log::error!(
+                        "failed to rollback worktree at {}: {rollback_err}",
+                        path.display()
+                    );
+                    rollback_failures.push(format!("{}: {rollback_err}", path.display()));
+                }
+            }
         }
-        self.serialize(cx);
-        cx.notify();
+        let mut error_message = format!("Failed to create worktree: {err}");
+        if !rollback_failures.is_empty() {
+            error_message.push_str("\n\nFailed to clean up: ");
+            error_message.push_str(&rollback_failures.join(", "));
+        }
+        Err(anyhow!(error_message))
     }
 
-    fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
-        if active && matches!(self.active_view, ActiveView::Uninitialized) {
+    fn set_worktree_creation_error(
+        &mut self,
+        message: SharedString,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.worktree_creation_status = Some(WorktreeCreationStatus::Error(message));
+        if matches!(self.active_view, ActiveView::Uninitialized) {
             let selected_agent = self.selected_agent.clone();
             self.new_agent_thread(selected_agent, window, cx);
         }
+        cx.notify();
     }
 
-    fn remote_id() -> Option<proto::PanelId> {
-        Some(proto::PanelId::AssistantPanel)
-    }
+    fn handle_worktree_creation_requested(
+        &mut self,
+        content: Vec<acp::ContentBlock>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if matches!(
+            self.worktree_creation_status,
+            Some(WorktreeCreationStatus::Creating)
+        ) {
+            return;
+        }
 
-    fn icon(&self, _window: &Window, cx: &App) -> Option<IconName> {
-        (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAssistant)
-    }
+        self.worktree_creation_status = Some(WorktreeCreationStatus::Creating);
+        cx.notify();
 
-    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
-        Some("Agent Panel")
-    }
+        let branch_name = Self::generate_agent_branch_name();
 
-    fn toggle_action(&self) -> Box<dyn Action> {
-        Box::new(ToggleFocus)
-    }
+        let (git_repos, non_git_paths) = self.classify_worktrees(cx);
 
-    fn activation_priority(&self) -> u32 {
-        3
-    }
+        if git_repos.is_empty() {
+            self.set_worktree_creation_error(
+                "No git repositories found in the project".into(),
+                window,
+                cx,
+            );
+            return;
+        }
 
-    fn enabled(&self, cx: &App) -> bool {
-        AgentSettings::get_global(cx).enabled(cx)
-    }
+        let worktree_directory_setting = ProjectSettings::get_global(cx)
+            .git
+            .worktree_directory
+            .clone();
 
-    fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool {
-        self.zoomed
-    }
+        let (creation_infos, path_remapping) = match Self::start_worktree_creations(
+            &git_repos,
+            &branch_name,
+            &worktree_directory_setting,
+            cx,
+        ) {
+            Ok(result) => result,
+            Err(err) => {
+                self.set_worktree_creation_error(
+                    format!("Failed to validate worktree directory: {err}").into(),
+                    window,
+                    cx,
+                );
+                return;
+            }
+        };
 
-    fn set_zoomed(&mut self, zoomed: bool, _window: &mut Window, cx: &mut Context<Self>) {
-        self.zoomed = zoomed;
-        cx.notify();
-    }
-}
+        let (dock_structure, open_file_paths) = self
+            .workspace
+            .upgrade()
+            .map(|workspace| {
+                let dock_structure = workspace.read(cx).capture_dock_state(window, cx);
+                let open_file_paths = workspace.read(cx).open_item_abs_paths(cx);
+                (dock_structure, open_file_paths)
+            })
+            .unwrap_or_default();
 
-impl AgentPanel {
-    fn render_title_view(&self, _window: &mut Window, cx: &Context<Self>) -> AnyElement {
-        const LOADING_SUMMARY_PLACEHOLDER: &str = "Loading Summary…";
+        let workspace = self.workspace.clone();
+        let window_handle = window
+            .window_handle()
+            .downcast::<workspace::MultiWorkspace>();
 
-        let content = match &self.active_view {
-            ActiveView::AgentThread { server_view } => {
-                let is_generating_title = server_view
-                    .read(cx)
-                    .as_native_thread(cx)
-                    .map_or(false, |t| t.read(cx).is_generating_title());
+        let task = cx.spawn_in(window, async move |this, cx| {
+            let created_paths = match Self::await_and_rollback_on_failure(creation_infos, cx).await
+            {
+                Ok(paths) => paths,
+                Err(err) => {
+                    this.update_in(cx, |this, window, cx| {
+                        this.set_worktree_creation_error(format!("{err}").into(), window, cx);
+                    })?;
+                    return anyhow::Ok(());
+                }
+            };
 
-                if let Some(title_editor) = server_view
+            let mut all_paths = created_paths;
+            let has_non_git = !non_git_paths.is_empty();
+            all_paths.extend(non_git_paths.iter().cloned());
+
+            let app_state = match workspace.upgrade() {
+                Some(workspace) => cx.update(|_, cx| workspace.read(cx).app_state().clone())?,
+                None => {
+                    this.update_in(cx, |this, window, cx| {
+                        this.set_worktree_creation_error(
+                            "Workspace no longer available".into(),
+                            window,
+                            cx,
+                        );
+                    })?;
+                    return anyhow::Ok(());
+                }
+            };
+
+            let this_for_error = this.clone();
+            if let Err(err) = Self::setup_new_workspace(
+                this,
+                all_paths,
+                app_state,
+                window_handle,
+                dock_structure,
+                open_file_paths,
+                path_remapping,
+                non_git_paths,
+                has_non_git,
+                content,
+                cx,
+            )
+            .await
+            {
+                this_for_error
+                    .update_in(cx, |this, window, cx| {
+                        this.set_worktree_creation_error(
+                            format!("Failed to set up workspace: {err}").into(),
+                            window,
+                            cx,
+                        );
+                    })
+                    .log_err();
+            }
+            anyhow::Ok(())
+        });
+
+        self._worktree_creation_task = Some(cx.foreground_executor().spawn(async move {
+            task.await.log_err();
+        }));
+    }
+
+    async fn setup_new_workspace(
+        this: WeakEntity<Self>,
+        all_paths: Vec<PathBuf>,
+        app_state: Arc<workspace::AppState>,
+        window_handle: Option<gpui::WindowHandle<workspace::MultiWorkspace>>,
+        dock_structure: workspace::DockStructure,
+        open_file_paths: Vec<PathBuf>,
+        path_remapping: Vec<(PathBuf, PathBuf)>,
+        non_git_paths: Vec<PathBuf>,
+        has_non_git: bool,
+        content: Vec<acp::ContentBlock>,
+        cx: &mut AsyncWindowContext,
+    ) -> Result<()> {
+        let init: Option<
+            Box<dyn FnOnce(&mut Workspace, &mut Window, &mut gpui::Context<Workspace>) + Send>,
+        > = Some(Box::new(move |workspace, window, cx| {
+            workspace.set_dock_structure(dock_structure, window, cx);
+        }));
+
+        let (new_window_handle, _) = cx
+            .update(|_window, cx| {
+                Workspace::new_local(all_paths, app_state, window_handle, None, init, false, cx)
+            })?
+            .await?;
+
+        let new_workspace = new_window_handle.update(cx, |multi_workspace, _window, _cx| {
+            let workspaces = multi_workspace.workspaces();
+            workspaces.last().cloned()
+        })?;
+
+        let Some(new_workspace) = new_workspace else {
+            anyhow::bail!("New workspace was not added to MultiWorkspace");
+        };
+
+        let panels_task = new_window_handle.update(cx, |_, _, cx| {
+            new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task())
+        })?;
+        if let Some(task) = panels_task {
+            task.await.log_err();
+        }
+
+        let initial_content = AgentInitialContent::ContentBlock {
+            blocks: content,
+            auto_submit: true,
+        };
+
+        new_window_handle.update(cx, |_multi_workspace, window, cx| {
+            new_workspace.update(cx, |workspace, cx| {
+                if has_non_git {
+                    let toast_id = workspace::notifications::NotificationId::unique::<AgentPanel>();
+                    workspace.show_toast(
+                        workspace::Toast::new(
+                            toast_id,
+                            "Some project folders are not git repositories. \
+                             They were included as-is without creating a worktree.",
+                        ),
+                        cx,
+                    );
+                }
+
+                let remapped_paths: Vec<PathBuf> = open_file_paths
+                    .iter()
+                    .filter_map(|original_path| {
+                        let best_match = path_remapping
+                            .iter()
+                            .filter_map(|(old_root, new_root)| {
+                                original_path.strip_prefix(old_root).ok().map(|relative| {
+                                    (old_root.components().count(), new_root.join(relative))
+                                })
+                            })
+                            .max_by_key(|(depth, _)| *depth);
+
+                        if let Some((_, remapped_path)) = best_match {
+                            return Some(remapped_path);
+                        }
+
+                        for non_git in &non_git_paths {
+                            if original_path.starts_with(non_git) {
+                                return Some(original_path.clone());
+                            }
+                        }
+                        None
+                    })
+                    .collect();
+
+                if !remapped_paths.is_empty() {
+                    workspace
+                        .open_paths(
+                            remapped_paths,
+                            workspace::OpenOptions::default(),
+                            None,
+                            window,
+                            cx,
+                        )
+                        .detach();
+                }
+
+                workspace.focus_panel::<AgentPanel>(window, cx);
+                if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+                    panel.update(cx, |panel, cx| {
+                        panel.external_thread(None, None, Some(initial_content), window, cx);
+                    });
+                }
+            });
+        })?;
+
+        new_window_handle.update(cx, |multi_workspace, _window, cx| {
+            multi_workspace.activate(new_workspace.clone(), cx);
+        })?;
+
+        this.update_in(cx, |this, _window, cx| {
+            this.worktree_creation_status = None;
+            cx.notify();
+        })?;
+
+        anyhow::Ok(())
+    }
+}
+
+impl Focusable for AgentPanel {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        match &self.active_view {
+            ActiveView::Uninitialized => self.focus_handle.clone(),
+            ActiveView::AgentThread { server_view, .. } => server_view.focus_handle(cx),
+            ActiveView::History { kind } => match kind {
+                HistoryKind::AgentThreads => self.acp_history.focus_handle(cx),
+                HistoryKind::TextThreads => self.text_thread_history.focus_handle(cx),
+            },
+            ActiveView::TextThread {
+                text_thread_editor, ..
+            } => text_thread_editor.focus_handle(cx),
+            ActiveView::Configuration => {
+                if let Some(configuration) = self.configuration.as_ref() {
+                    configuration.focus_handle(cx)
+                } else {
+                    self.focus_handle.clone()
+                }
+            }
+        }
+    }
+}
+
+fn agent_panel_dock_position(cx: &App) -> DockPosition {
+    AgentSettings::get_global(cx).dock.into()
+}
+
+pub enum AgentPanelEvent {
+    ActiveViewChanged,
+}
+
+impl EventEmitter<PanelEvent> for AgentPanel {}
+impl EventEmitter<AgentPanelEvent> for AgentPanel {}
+
+impl Panel for AgentPanel {
+    fn persistent_name() -> &'static str {
+        "AgentPanel"
+    }
+
+    fn panel_key() -> &'static str {
+        AGENT_PANEL_KEY
+    }
+
+    fn position(&self, _window: &Window, cx: &App) -> DockPosition {
+        agent_panel_dock_position(cx)
+    }
+
+    fn position_is_valid(&self, position: DockPosition) -> bool {
+        position != DockPosition::Bottom
+    }
+
+    fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
+        settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
+            settings
+                .agent
+                .get_or_insert_default()
+                .set_dock(position.into());
+        });
+    }
+
+    fn size(&self, window: &Window, cx: &App) -> Pixels {
+        let settings = AgentSettings::get_global(cx);
+        match self.position(window, cx) {
+            DockPosition::Left | DockPosition::Right => {
+                self.width.unwrap_or(settings.default_width)
+            }
+            DockPosition::Bottom => self.height.unwrap_or(settings.default_height),
+        }
+    }
+
+    fn set_size(&mut self, size: Option<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
+        match self.position(window, cx) {
+            DockPosition::Left | DockPosition::Right => self.width = size,
+            DockPosition::Bottom => self.height = size,
+        }
+        self.serialize(cx);
+        cx.notify();
+    }
+
+    fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
+        if active
+            && matches!(self.active_view, ActiveView::Uninitialized)
+            && !matches!(
+                self.worktree_creation_status,
+                Some(WorktreeCreationStatus::Creating)
+            )
+        {
+            let selected_agent = self.selected_agent.clone();
+            self.new_agent_thread(selected_agent, window, cx);
+        }
+    }
+
+    fn remote_id() -> Option<proto::PanelId> {
+        Some(proto::PanelId::AssistantPanel)
+    }
+
+    fn icon(&self, _window: &Window, cx: &App) -> Option<IconName> {
+        (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAssistant)
+    }
+
+    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
+        Some("Agent Panel")
+    }
+
+    fn toggle_action(&self) -> Box<dyn Action> {
+        Box::new(ToggleFocus)
+    }
+
+    fn activation_priority(&self) -> u32 {
+        3
+    }
+
+    fn enabled(&self, cx: &App) -> bool {
+        AgentSettings::get_global(cx).enabled(cx)
+    }
+
+    fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool {
+        self.zoomed
+    }
+
+    fn set_zoomed(&mut self, zoomed: bool, _window: &mut Window, cx: &mut Context<Self>) {
+        self.zoomed = zoomed;
+        cx.notify();
+    }
+}
+
+impl AgentPanel {
+    fn render_title_view(&self, _window: &mut Window, cx: &Context<Self>) -> AnyElement {
+        const LOADING_SUMMARY_PLACEHOLDER: &str = "Loading Summary…";
+
+        let content = match &self.active_view {
+            ActiveView::AgentThread { server_view } => {
+                let is_generating_title = server_view
+                    .read(cx)
+                    .as_native_thread(cx)
+                    .map_or(false, |t| t.read(cx).is_generating_title());
+
+                if let Some(title_editor) = server_view
                     .read(cx)
                     .parent_thread(cx)
                     .map(|r| r.read(cx).title_editor.clone())

crates/agent_ui/src/agent_ui.rs 🔗

@@ -55,7 +55,9 @@ use std::any::TypeId;
 use workspace::Workspace;
 
 use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal};
-pub use crate::agent_panel::{AgentPanel, AgentPanelEvent, ConcreteAssistantPanelDelegate};
+pub use crate::agent_panel::{
+    AgentPanel, AgentPanelEvent, ConcreteAssistantPanelDelegate, WorktreeCreationStatus,
+};
 use crate::agent_registry_ui::AgentRegistryPage;
 pub use crate::inline_assistant::InlineAssistant;
 pub use agent_diff::{AgentDiffPane, AgentDiffToolbar};
@@ -222,6 +224,18 @@ impl ExternalAgent {
     }
 }
 
+/// Sets where new threads will run.
+#[derive(
+    Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action,
+)]
+#[action(namespace = agent)]
+#[serde(rename_all = "snake_case", tag = "kind")]
+pub enum StartThreadIn {
+    #[default]
+    LocalProject,
+    NewWorktree,
+}
+
 /// Content to initialize new external agent with.
 pub enum AgentInitialContent {
     ThreadSummary(acp_thread::AgentSessionInfo),

crates/agent_ui/src/connection_view.rs 🔗

@@ -26,10 +26,10 @@ use fs::Fs;
 use futures::FutureExt as _;
 use gpui::{
     Action, Animation, AnimationExt, AnyView, App, ClickEvent, ClipboardItem, CursorStyle,
-    ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, ListOffset, ListState, ObjectFit,
-    PlatformDisplay, ScrollHandle, SharedString, Subscription, Task, TextStyle, WeakEntity, Window,
-    WindowHandle, div, ease_in_out, img, linear_color_stop, linear_gradient, list, point,
-    pulsating_between,
+    ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, ListOffset, ListState,
+    ObjectFit, PlatformDisplay, ScrollHandle, SharedString, Subscription, Task, TextStyle,
+    WeakEntity, Window, WindowHandle, div, ease_in_out, img, linear_color_stop, linear_gradient,
+    list, point, pulsating_between,
 };
 use language::Buffer;
 use language_model::LanguageModelRegistry;
@@ -295,6 +295,12 @@ impl Conversation {
     }
 }
 
+pub enum AcpServerViewEvent {
+    ActiveThreadChanged,
+}
+
+impl EventEmitter<AcpServerViewEvent> for ConnectionView {}
+
 pub struct ConnectionView {
     agent: Rc<dyn AgentServer>,
     agent_server_store: Entity<AgentServerStore>,
@@ -386,6 +392,7 @@ impl ConnectionView {
         if let Some(view) = self.active_thread() {
             view.focus_handle(cx).focus(window, cx);
         }
+        cx.emit(AcpServerViewEvent::ActiveThreadChanged);
         cx.notify();
     }
 }
@@ -524,6 +531,7 @@ impl ConnectionView {
         }
 
         self.server_state = state;
+        cx.emit(AcpServerViewEvent::ActiveThreadChanged);
         cx.notify();
     }
 

crates/agent_ui/src/connection_view/thread_view.rs 🔗

@@ -1,6 +1,8 @@
 use acp_thread::ContentBlock;
 use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody};
 use editor::actions::OpenExcerpts;
+
+use crate::StartThreadIn;
 use gpui::{Corner, List};
 use language_model::{LanguageModelEffortLevel, Speed};
 use settings::update_settings_file;
@@ -191,6 +193,12 @@ impl DiffStats {
     }
 }
 
+pub enum AcpThreadViewEvent {
+    FirstSendRequested { content: Vec<acp::ContentBlock> },
+}
+
+impl EventEmitter<AcpThreadViewEvent> for ThreadView {}
+
 pub struct ThreadView {
     pub id: acp::SessionId,
     pub parent_id: Option<acp::SessionId>,
@@ -518,6 +526,24 @@ impl ThreadView {
             .thread(acp_thread.session_id(), cx)
     }
 
+    /// Resolves the message editor's contents into content blocks. For profiles
+    /// that do not enable any tools, directory mentions are expanded to inline
+    /// file contents since the agent can't read files on its own.
+    fn resolve_message_contents(
+        &self,
+        message_editor: &Entity<MessageEditor>,
+        cx: &mut App,
+    ) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
+        let expand = self.as_native_thread(cx).is_some_and(|thread| {
+            let thread = thread.read(cx);
+            AgentSettings::get_global(cx)
+                .profiles
+                .get(thread.profile())
+                .is_some_and(|profile| profile.tools.is_empty())
+        });
+        message_editor.update(cx, |message_editor, cx| message_editor.contents(expand, cx))
+    }
+
     pub fn current_model_id(&self, cx: &App) -> Option<String> {
         let selector = self.model_selector.as_ref()?;
         let model = selector.read(cx).active_model(cx)?;
@@ -731,6 +757,46 @@ impl ThreadView {
         }
 
         let message_editor = self.message_editor.clone();
+
+        // Intercept the first send so the agent panel can capture the full
+        // content blocks — needed for "Start thread in New Worktree",
+        // which must create a workspace before sending the message there.
+        let intercept_first_send = self.thread.read(cx).entries().is_empty()
+            && !message_editor.read(cx).is_empty(cx)
+            && self
+                .workspace
+                .upgrade()
+                .and_then(|workspace| workspace.read(cx).panel::<AgentPanel>(cx))
+                .is_some_and(|panel| {
+                    panel.read(cx).start_thread_in() == &StartThreadIn::NewWorktree
+                });
+
+        if intercept_first_send {
+            let content_task = self.resolve_message_contents(&message_editor, cx);
+
+            cx.spawn(async move |this, cx| match content_task.await {
+                Ok((content, _tracked_buffers)) => {
+                    if content.is_empty() {
+                        return;
+                    }
+
+                    this.update(cx, |_, cx| {
+                        cx.emit(AcpThreadViewEvent::FirstSendRequested { content });
+                    })
+                    .ok();
+                }
+                Err(error) => {
+                    this.update(cx, |this, cx| {
+                        this.handle_thread_error(error, cx);
+                    })
+                    .ok();
+                }
+            })
+            .detach();
+
+            return;
+        }
+
         let is_editor_empty = message_editor.read(cx).is_empty(cx);
         let is_generating = thread.read(cx).status() != ThreadStatus::Idle;
 
@@ -794,18 +860,7 @@ impl ThreadView {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let full_mention_content = self.as_native_thread(cx).is_some_and(|thread| {
-            // Include full contents when using minimal profile
-            let thread = thread.read(cx);
-            AgentSettings::get_global(cx)
-                .profiles
-                .get(thread.profile())
-                .is_some_and(|profile| profile.tools.is_empty())
-        });
-
-        let contents = message_editor.update(cx, |message_editor, cx| {
-            message_editor.contents(full_mention_content, cx)
-        });
+        let contents = self.resolve_message_contents(&message_editor, cx);
 
         self.thread_error.take();
         self.thread_feedback.clear();
@@ -1140,21 +1195,11 @@ impl ThreadView {
         let is_idle = self.thread.read(cx).status() == acp_thread::ThreadStatus::Idle;
 
         if is_idle {
-            self.send_impl(message_editor.clone(), window, cx);
+            self.send_impl(message_editor, window, cx);
             return;
         }
 
-        let full_mention_content = self.as_native_thread(cx).is_some_and(|thread| {
-            let thread = thread.read(cx);
-            AgentSettings::get_global(cx)
-                .profiles
-                .get(thread.profile())
-                .is_some_and(|profile| profile.tools.is_empty())
-        });
-
-        let contents = message_editor.update(cx, |message_editor, cx| {
-            message_editor.contents(full_mention_content, cx)
-        });
+        let contents = self.resolve_message_contents(&message_editor, cx);
 
         cx.spawn_in(window, async move |this, cx| {
             let (content, tracked_buffers) = contents.await?;

crates/collab/src/db/queries/projects.rs 🔗

@@ -1002,7 +1002,7 @@ impl Database {
                     repositories.push(proto::UpdateRepository {
                         project_id: db_repository_entry.project_id.0 as u64,
                         id: db_repository_entry.id as u64,
-                        abs_path: db_repository_entry.abs_path,
+                        abs_path: db_repository_entry.abs_path.clone(),
                         entry_ids,
                         updated_statuses,
                         removed_statuses: Vec::new(),
@@ -1015,6 +1015,7 @@ impl Database {
                         stash_entries: Vec::new(),
                         remote_upstream_url: db_repository_entry.remote_upstream_url.clone(),
                         remote_origin_url: db_repository_entry.remote_origin_url.clone(),
+                        original_repo_abs_path: Some(db_repository_entry.abs_path),
                     });
                 }
             }

crates/collab/src/db/queries/rooms.rs 🔗

@@ -791,13 +791,14 @@ impl Database {
                             head_commit_details,
                             project_id: project_id.to_proto(),
                             id: db_repository.id as u64,
-                            abs_path: db_repository.abs_path,
+                            abs_path: db_repository.abs_path.clone(),
                             scan_id: db_repository.scan_id as u64,
                             is_last_update: true,
                             merge_message: db_repository.merge_message,
                             stash_entries: Vec::new(),
                             remote_upstream_url: db_repository.remote_upstream_url.clone(),
                             remote_origin_url: db_repository.remote_origin_url.clone(),
+                            original_repo_abs_path: Some(db_repository.abs_path),
                         });
                     }
                 }

crates/feature_flags/src/flags.rs 🔗

@@ -37,6 +37,16 @@ impl FeatureFlag for AgentSharingFeatureFlag {
     const NAME: &'static str = "agent-sharing";
 }
 
+pub struct AgentGitWorktreesFeatureFlag;
+
+impl FeatureFlag for AgentGitWorktreesFeatureFlag {
+    const NAME: &'static str = "agent-git-worktrees";
+
+    fn enabled_for_staff() -> bool {
+        false
+    }
+}
+
 pub struct DiffReviewFeatureFlag;
 
 impl FeatureFlag for DiffReviewFeatureFlag {

crates/git/src/repository.rs 🔗

@@ -55,6 +55,26 @@ pub const GRAPH_CHUNK_SIZE: usize = 1000;
 /// Default value for the `git.worktree_directory` setting.
 pub const DEFAULT_WORKTREE_DIRECTORY: &str = "../worktrees";
 
+/// Given the git common directory (from `commondir()`), derive the original
+/// repository's working directory.
+///
+/// For a standard checkout, `common_dir` is `<work_dir>/.git`, so the parent
+/// is the working directory. For a git worktree, `common_dir` is the **main**
+/// repo's `.git` directory, so the parent is the original repo's working directory.
+///
+/// Falls back to returning `common_dir` itself if it doesn't end with `.git`
+/// (e.g. bare repos or unusual layouts).
+pub fn original_repo_path_from_common_dir(common_dir: &Path) -> PathBuf {
+    if common_dir.file_name() == Some(OsStr::new(".git")) {
+        common_dir
+            .parent()
+            .map(|p| p.to_path_buf())
+            .unwrap_or_else(|| common_dir.to_path_buf())
+    } else {
+        common_dir.to_path_buf()
+    }
+}
+
 /// Resolves the configured worktree directory to an absolute path.
 ///
 /// `worktree_directory_setting` is the raw string from the user setting
@@ -4272,6 +4292,34 @@ mod tests {
         );
     }
 
+    #[test]
+    fn test_original_repo_path_from_common_dir() {
+        // Normal repo: common_dir is <work_dir>/.git
+        assert_eq!(
+            original_repo_path_from_common_dir(Path::new("/code/zed5/.git")),
+            PathBuf::from("/code/zed5")
+        );
+
+        // Worktree: common_dir is the main repo's .git
+        // (same result — that's the point, it always traces back to the original)
+        assert_eq!(
+            original_repo_path_from_common_dir(Path::new("/code/zed5/.git")),
+            PathBuf::from("/code/zed5")
+        );
+
+        // Bare repo: no .git suffix, returns as-is
+        assert_eq!(
+            original_repo_path_from_common_dir(Path::new("/code/zed5.git")),
+            PathBuf::from("/code/zed5.git")
+        );
+
+        // Root-level .git directory
+        assert_eq!(
+            original_repo_path_from_common_dir(Path::new("/.git")),
+            PathBuf::from("/")
+        );
+    }
+
     #[test]
     fn test_validate_worktree_directory() {
         let work_dir = Path::new("/code/my-project");

crates/git_ui/src/worktree_picker.rs 🔗

@@ -275,9 +275,9 @@ impl WorktreeListDelegate {
                     .git
                     .worktree_directory
                     .clone();
-                let work_dir = repo.work_directory_abs_path.clone();
+                let original_repo = repo.original_repo_abs_path.clone();
                 let directory =
-                    validate_worktree_directory(&work_dir, &worktree_directory_setting)?;
+                    validate_worktree_directory(&original_repo, &worktree_directory_setting)?;
                 let new_worktree_path = directory.join(&branch);
                 let receiver = repo.create_worktree(branch.clone(), directory, commit);
                 anyhow::Ok((receiver, new_worktree_path))

crates/project/src/git_store.rs 🔗

@@ -266,6 +266,11 @@ pub struct RepositorySnapshot {
     pub id: RepositoryId,
     pub statuses_by_path: SumTree<StatusEntry>,
     pub work_directory_abs_path: Arc<Path>,
+    /// The working directory of the original repository. For a normal
+    /// checkout this equals `work_directory_abs_path`. For a git worktree
+    /// checkout, this is the original repo's working directory — used to
+    /// anchor new worktree creation so they don't nest.
+    pub original_repo_abs_path: Arc<Path>,
     pub path_style: PathStyle,
     pub branch: Option<Branch>,
     pub head_commit: Option<CommitDetails>,
@@ -1505,16 +1510,19 @@ impl GitStore {
                 new_work_directory_abs_path: Some(work_directory_abs_path),
                 dot_git_abs_path: Some(dot_git_abs_path),
                 repository_dir_abs_path: Some(_repository_dir_abs_path),
-                common_dir_abs_path: Some(_common_dir_abs_path),
+                common_dir_abs_path: Some(common_dir_abs_path),
                 ..
             } = update
             {
+                let original_repo_abs_path: Arc<Path> =
+                    git::repository::original_repo_path_from_common_dir(common_dir_abs_path).into();
                 let id = RepositoryId(next_repository_id.fetch_add(1, atomic::Ordering::Release));
                 let git_store = cx.weak_entity();
                 let repo = cx.new(|cx| {
                     let mut repo = Repository::local(
                         id,
                         work_directory_abs_path.clone(),
+                        original_repo_abs_path.clone(),
                         dot_git_abs_path.clone(),
                         project_environment.downgrade(),
                         fs.clone(),
@@ -1840,6 +1848,11 @@ impl GitStore {
             let id = RepositoryId::from_proto(update.id);
             let client = this.upstream_client().context("no upstream client")?;
 
+            let original_repo_abs_path: Option<Arc<Path>> = update
+                .original_repo_abs_path
+                .as_deref()
+                .map(|p| Path::new(p).into());
+
             let mut repo_subscription = None;
             let repo = this.repositories.entry(id).or_insert_with(|| {
                 let git_store = cx.weak_entity();
@@ -1847,6 +1860,7 @@ impl GitStore {
                     Repository::remote(
                         id,
                         Path::new(&update.abs_path).into(),
+                        original_repo_abs_path.clone(),
                         path_style,
                         ProjectId(update.project_id),
                         client,
@@ -3481,10 +3495,17 @@ impl RepositoryId {
 }
 
 impl RepositorySnapshot {
-    fn empty(id: RepositoryId, work_directory_abs_path: Arc<Path>, path_style: PathStyle) -> Self {
+    fn empty(
+        id: RepositoryId,
+        work_directory_abs_path: Arc<Path>,
+        original_repo_abs_path: Option<Arc<Path>>,
+        path_style: PathStyle,
+    ) -> Self {
         Self {
             id,
             statuses_by_path: Default::default(),
+            original_repo_abs_path: original_repo_abs_path
+                .unwrap_or_else(|| work_directory_abs_path.clone()),
             work_directory_abs_path,
             branch: None,
             head_commit: None,
@@ -3528,6 +3549,9 @@ impl RepositorySnapshot {
                 .collect(),
             remote_upstream_url: self.remote_upstream_url.clone(),
             remote_origin_url: self.remote_origin_url.clone(),
+            original_repo_abs_path: Some(
+                self.original_repo_abs_path.to_string_lossy().into_owned(),
+            ),
         }
     }
 
@@ -3599,6 +3623,9 @@ impl RepositorySnapshot {
                 .collect(),
             remote_upstream_url: self.remote_upstream_url.clone(),
             remote_origin_url: self.remote_origin_url.clone(),
+            original_repo_abs_path: Some(
+                self.original_repo_abs_path.to_string_lossy().into_owned(),
+            ),
         }
     }
 
@@ -3757,14 +3784,19 @@ impl Repository {
     fn local(
         id: RepositoryId,
         work_directory_abs_path: Arc<Path>,
+        original_repo_abs_path: Arc<Path>,
         dot_git_abs_path: Arc<Path>,
         project_environment: WeakEntity<ProjectEnvironment>,
         fs: Arc<dyn Fs>,
         git_store: WeakEntity<GitStore>,
         cx: &mut Context<Self>,
     ) -> Self {
-        let snapshot =
-            RepositorySnapshot::empty(id, work_directory_abs_path.clone(), PathStyle::local());
+        let snapshot = RepositorySnapshot::empty(
+            id,
+            work_directory_abs_path.clone(),
+            Some(original_repo_abs_path),
+            PathStyle::local(),
+        );
         let state = cx
             .spawn(async move |_, cx| {
                 LocalRepositoryState::new(
@@ -3818,13 +3850,19 @@ impl Repository {
     fn remote(
         id: RepositoryId,
         work_directory_abs_path: Arc<Path>,
+        original_repo_abs_path: Option<Arc<Path>>,
         path_style: PathStyle,
         project_id: ProjectId,
         client: AnyProtoClient,
         git_store: WeakEntity<GitStore>,
         cx: &mut Context<Self>,
     ) -> Self {
-        let snapshot = RepositorySnapshot::empty(id, work_directory_abs_path, path_style);
+        let snapshot = RepositorySnapshot::empty(
+            id,
+            work_directory_abs_path,
+            original_repo_abs_path,
+            path_style,
+        );
         let repository_state = RemoteRepositoryState { project_id, client };
         let job_sender = Self::spawn_remote_git_worker(repository_state.clone(), cx);
         let repository_state = Task::ready(Ok(RepositoryState::Remote(repository_state))).shared();
@@ -5650,6 +5688,24 @@ impl Repository {
         )
     }
 
+    pub fn remove_worktree(&mut self, path: PathBuf, force: bool) -> oneshot::Receiver<Result<()>> {
+        self.send_job(
+            Some("git worktree remove".into()),
+            move |repo, _cx| async move {
+                match repo {
+                    RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
+                        backend.remove_worktree(path, force).await
+                    }
+                    RepositoryState::Remote(_) => {
+                        anyhow::bail!(
+                            "Removing worktrees on remote repositories is not yet supported"
+                        )
+                    }
+                }
+            },
+        )
+    }
+
     pub fn default_branch(
         &mut self,
         include_remote_name: bool,
@@ -5988,6 +6044,10 @@ impl Repository {
         update: proto::UpdateRepository,
         cx: &mut Context<Self>,
     ) -> Result<()> {
+        if let Some(main_path) = &update.original_repo_abs_path {
+            self.snapshot.original_repo_abs_path = Path::new(main_path.as_str()).into();
+        }
+
         let new_branch = update.branch_summary.as_ref().map(proto_to_branch);
         let new_head_commit = update
             .head_commit_details
@@ -6784,6 +6844,7 @@ async fn compute_snapshot(
         id,
         statuses_by_path,
         work_directory_abs_path,
+        original_repo_abs_path: prev_snapshot.original_repo_abs_path,
         path_style: prev_snapshot.path_style,
         scan_id: prev_snapshot.scan_id + 1,
         branch,

crates/proto/proto/git.proto 🔗

@@ -125,6 +125,7 @@ message UpdateRepository {
   repeated StashEntry stash_entries = 13;
   optional string remote_upstream_url = 14;
   optional string remote_origin_url = 15;
+  optional string original_repo_abs_path = 16;
 }
 
 message RemoveRepository {

crates/workspace/src/persistence/model.rs 🔗

@@ -93,9 +93,9 @@ pub(crate) struct SerializedWorkspace {
 
 #[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize)]
 pub struct DockStructure {
-    pub(crate) left: DockData,
-    pub(crate) right: DockData,
-    pub(crate) bottom: DockData,
+    pub left: DockData,
+    pub right: DockData,
+    pub bottom: DockData,
 }
 
 impl RemoteConnectionKind {
@@ -143,9 +143,9 @@ impl Bind for DockStructure {
 
 #[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize)]
 pub struct DockData {
-    pub(crate) visible: bool,
-    pub(crate) active_panel: Option<String>,
-    pub(crate) zoom: bool,
+    pub visible: bool,
+    pub active_panel: Option<String>,
+    pub zoom: bool,
 }
 
 impl Column for DockData {

crates/workspace/src/workspace.rs 🔗

@@ -79,7 +79,10 @@ pub use pane_group::{
 use persistence::{DB, SerializedWindowBounds, model::SerializedWorkspace};
 pub use persistence::{
     DB as WORKSPACE_DB, WorkspaceDb, delete_unloaded_items,
-    model::{ItemId, SerializedMultiWorkspace, SerializedWorkspaceLocation, SessionWorkspace},
+    model::{
+        DockStructure, ItemId, SerializedMultiWorkspace, SerializedWorkspaceLocation,
+        SessionWorkspace,
+    },
     read_serialized_multi_workspaces,
 };
 use postage::stream::Stream;
@@ -149,7 +152,7 @@ use crate::{item::ItemBufferKind, notifications::NotificationId};
 use crate::{
     persistence::{
         SerializedAxis,
-        model::{DockData, DockStructure, SerializedItem, SerializedPane, SerializedPaneGroup},
+        model::{DockData, SerializedItem, SerializedPane, SerializedPaneGroup},
     },
     security_modal::SecurityModal,
 };
@@ -628,7 +631,7 @@ fn prompt_and_open_paths(app_state: Arc<AppState>, options: PathPromptOptions, c
             })
             .ok();
     } else {
-        let task = Workspace::new_local(Vec::new(), app_state.clone(), None, None, None, cx);
+        let task = Workspace::new_local(Vec::new(), app_state.clone(), None, None, None, true, cx);
         cx.spawn(async move |cx| {
             let (window, _) = task.await?;
             window.update(cx, |multi_workspace, window, cx| {
@@ -1290,6 +1293,7 @@ pub struct Workspace {
     scheduled_tasks: Vec<Task<()>>,
     last_open_dock_positions: Vec<DockPosition>,
     removing: bool,
+    _panels_task: Option<Task<Result<()>>>,
 }
 
 impl EventEmitter<Event> for Workspace {}
@@ -1660,6 +1664,7 @@ impl Workspace {
             left_dock,
             bottom_dock,
             right_dock,
+            _panels_task: None,
             project: project.clone(),
             follower_states: Default::default(),
             last_leaders_by_pane: Default::default(),
@@ -1703,6 +1708,7 @@ impl Workspace {
         requesting_window: Option<WindowHandle<MultiWorkspace>>,
         env: Option<HashMap<String, String>>,
         init: Option<Box<dyn FnOnce(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send>>,
+        activate: bool,
         cx: &mut App,
     ) -> Task<
         anyhow::Result<(
@@ -1830,7 +1836,11 @@ impl Workspace {
 
                             workspace
                         });
-                        multi_workspace.activate(workspace.clone(), cx);
+                        if activate {
+                            multi_workspace.activate(workspace.clone(), cx);
+                        } else {
+                            multi_workspace.add_workspace(workspace.clone(), cx);
+                        }
                         workspace
                     })?;
                     (window, workspace)
@@ -1984,6 +1994,76 @@ impl Workspace {
         [&self.left_dock, &self.bottom_dock, &self.right_dock]
     }
 
+    pub fn capture_dock_state(&self, _window: &Window, cx: &App) -> DockStructure {
+        let left_dock = self.left_dock.read(cx);
+        let left_visible = left_dock.is_open();
+        let left_active_panel = left_dock
+            .active_panel()
+            .map(|panel| panel.persistent_name().to_string());
+        // `zoomed_position` is kept in sync with individual panel zoom state
+        // by the dock code in `Dock::new` and `Dock::add_panel`.
+        let left_dock_zoom = self.zoomed_position == Some(DockPosition::Left);
+
+        let right_dock = self.right_dock.read(cx);
+        let right_visible = right_dock.is_open();
+        let right_active_panel = right_dock
+            .active_panel()
+            .map(|panel| panel.persistent_name().to_string());
+        let right_dock_zoom = self.zoomed_position == Some(DockPosition::Right);
+
+        let bottom_dock = self.bottom_dock.read(cx);
+        let bottom_visible = bottom_dock.is_open();
+        let bottom_active_panel = bottom_dock
+            .active_panel()
+            .map(|panel| panel.persistent_name().to_string());
+        let bottom_dock_zoom = self.zoomed_position == Some(DockPosition::Bottom);
+
+        DockStructure {
+            left: DockData {
+                visible: left_visible,
+                active_panel: left_active_panel,
+                zoom: left_dock_zoom,
+            },
+            right: DockData {
+                visible: right_visible,
+                active_panel: right_active_panel,
+                zoom: right_dock_zoom,
+            },
+            bottom: DockData {
+                visible: bottom_visible,
+                active_panel: bottom_active_panel,
+                zoom: bottom_dock_zoom,
+            },
+        }
+    }
+
+    pub fn set_dock_structure(
+        &self,
+        docks: DockStructure,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        for (dock, data) in [
+            (&self.left_dock, docks.left),
+            (&self.bottom_dock, docks.bottom),
+            (&self.right_dock, docks.right),
+        ] {
+            dock.update(cx, |dock, cx| {
+                dock.serialized_dock = Some(data);
+                dock.restore_state(window, cx);
+            });
+        }
+    }
+
+    pub fn open_item_abs_paths(&self, cx: &App) -> Vec<PathBuf> {
+        self.items(cx)
+            .filter_map(|item| {
+                let project_path = item.project_path(cx)?;
+                self.project.read(cx).absolute_path(&project_path, cx)
+            })
+            .collect()
+    }
+
     pub fn dock_at_position(&self, position: DockPosition) -> &Entity<Dock> {
         match position {
             DockPosition::Left => &self.left_dock,
@@ -2043,6 +2123,14 @@ impl Workspace {
         &self.app_state
     }
 
+    pub fn set_panels_task(&mut self, task: Task<Result<()>>) {
+        self._panels_task = Some(task);
+    }
+
+    pub fn take_panels_task(&mut self) -> Option<Task<Result<()>>> {
+        self._panels_task.take()
+    }
+
     pub fn user_store(&self) -> &Entity<UserStore> {
         &self.app_state.user_store
     }
@@ -2548,7 +2636,15 @@ impl Workspace {
             Task::ready(Ok(callback(self, window, cx)))
         } else {
             let env = self.project.read(cx).cli_environment(cx);
-            let task = Self::new_local(Vec::new(), self.app_state.clone(), None, env, None, cx);
+            let task = Self::new_local(
+                Vec::new(),
+                self.app_state.clone(),
+                None,
+                env,
+                None,
+                true,
+                cx,
+            );
             cx.spawn_in(window, async move |_vh, cx| {
                 let (multi_workspace_window, _) = task.await?;
                 multi_workspace_window.update(cx, |multi_workspace, window, cx| {
@@ -2578,7 +2674,15 @@ impl Workspace {
             Task::ready(Ok(callback(self, window, cx)))
         } else {
             let env = self.project.read(cx).cli_environment(cx);
-            let task = Self::new_local(Vec::new(), self.app_state.clone(), None, env, None, cx);
+            let task = Self::new_local(
+                Vec::new(),
+                self.app_state.clone(),
+                None,
+                env,
+                None,
+                true,
+                cx,
+            );
             cx.spawn_in(window, async move |_vh, cx| {
                 let (multi_workspace_window, _) = task.await?;
                 multi_workspace_window.update(cx, |multi_workspace, window, cx| {
@@ -6012,53 +6116,7 @@ impl Workspace {
             window: &mut Window,
             cx: &mut App,
         ) -> DockStructure {
-            let left_dock = this.left_dock.read(cx);
-            let left_visible = left_dock.is_open();
-            let left_active_panel = left_dock
-                .active_panel()
-                .map(|panel| panel.persistent_name().to_string());
-            let left_dock_zoom = left_dock
-                .active_panel()
-                .map(|panel| panel.is_zoomed(window, cx))
-                .unwrap_or(false);
-
-            let right_dock = this.right_dock.read(cx);
-            let right_visible = right_dock.is_open();
-            let right_active_panel = right_dock
-                .active_panel()
-                .map(|panel| panel.persistent_name().to_string());
-            let right_dock_zoom = right_dock
-                .active_panel()
-                .map(|panel| panel.is_zoomed(window, cx))
-                .unwrap_or(false);
-
-            let bottom_dock = this.bottom_dock.read(cx);
-            let bottom_visible = bottom_dock.is_open();
-            let bottom_active_panel = bottom_dock
-                .active_panel()
-                .map(|panel| panel.persistent_name().to_string());
-            let bottom_dock_zoom = bottom_dock
-                .active_panel()
-                .map(|panel| panel.is_zoomed(window, cx))
-                .unwrap_or(false);
-
-            DockStructure {
-                left: DockData {
-                    visible: left_visible,
-                    active_panel: left_active_panel,
-                    zoom: left_dock_zoom,
-                },
-                right: DockData {
-                    visible: right_visible,
-                    active_panel: right_active_panel,
-                    zoom: right_dock_zoom,
-                },
-                bottom: DockData {
-                    visible: bottom_visible,
-                    active_panel: bottom_active_panel,
-                    zoom: bottom_dock_zoom,
-                },
-            }
+            this.capture_dock_state(window, cx)
         }
 
         match self.workspace_location(cx) {
@@ -8087,6 +8145,7 @@ pub async fn restore_multiworkspace(
                     None,
                     None,
                     None,
+                    true,
                     cx,
                 )
             })
@@ -8116,6 +8175,7 @@ pub async fn restore_multiworkspace(
                     Some(window_handle),
                     None,
                     None,
+                    true,
                     cx,
                 )
             })
@@ -8385,6 +8445,7 @@ pub fn join_channel(
                         requesting_window,
                         None,
                         None,
+                        true,
                         cx,
                     )
                 })
@@ -8457,7 +8518,7 @@ pub async fn get_any_active_multi_workspace(
     // find an existing workspace to focus and show call controls
     let active_window = activate_any_workspace_window(&mut cx);
     if active_window.is_none() {
-        cx.update(|cx| Workspace::new_local(vec![], app_state.clone(), None, None, None, cx))
+        cx.update(|cx| Workspace::new_local(vec![], app_state.clone(), None, None, None, true, cx))
             .await?;
     }
     activate_any_workspace_window(&mut cx).context("could not open zed")
@@ -8845,6 +8906,7 @@ pub fn open_paths(
                         open_options.replace_window,
                         open_options.env,
                         None,
+                        true,
                         cx,
                     )
                 })
@@ -8908,6 +8970,7 @@ pub fn open_new(
         open_options.replace_window,
         open_options.env,
         Some(Box::new(init)),
+        true,
         cx,
     );
     cx.spawn(async move |cx| {

crates/zed/src/visual_test_runner.rs 🔗

@@ -71,7 +71,7 @@ use {
         time::Duration,
     },
     util::ResultExt as _,
-    workspace::{AppState, MultiWorkspace, Workspace, WorkspaceId},
+    workspace::{AppState, MultiWorkspace, Panel as _, Workspace, WorkspaceId},
     zed_actions::OpenSettingsAt,
 };
 
@@ -548,6 +548,27 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()>
         }
     }
 
+    // Run Test 11: Thread target selector visual tests
+    #[cfg(feature = "visual-tests")]
+    {
+        println!("\n--- Test 11: start_thread_in_selector (6 variants) ---");
+        match run_start_thread_in_selector_visual_tests(app_state.clone(), &mut cx, update_baseline)
+        {
+            Ok(TestResult::Passed) => {
+                println!("✓ start_thread_in_selector: PASSED");
+                passed += 1;
+            }
+            Ok(TestResult::BaselineUpdated(_)) => {
+                println!("✓ start_thread_in_selector: Baselines updated");
+                updated += 1;
+            }
+            Err(e) => {
+                eprintln!("✗ start_thread_in_selector: FAILED - {}", e);
+                failed += 1;
+            }
+        }
+    }
+
     // Run Test 9: Tool Permissions Settings UI visual test
     println!("\n--- Test 9: tool_permissions_settings ---");
     match run_tool_permissions_visual_tests(app_state.clone(), &mut cx, update_baseline) {
@@ -3066,3 +3087,629 @@ fn run_error_wrapping_visual_tests(
 
     Ok(test_result)
 }
+
+#[cfg(all(target_os = "macos", feature = "visual-tests"))]
+/// Runs a git command in the given directory and returns an error with
+/// stderr/stdout context if the command fails (non-zero exit status).
+fn run_git_command(args: &[&str], dir: &std::path::Path) -> Result<()> {
+    let output = std::process::Command::new("git")
+        .args(args)
+        .current_dir(dir)
+        .output()
+        .with_context(|| format!("failed to spawn `git {}`", args.join(" ")))?;
+
+    if !output.status.success() {
+        let stdout = String::from_utf8_lossy(&output.stdout);
+        let stderr = String::from_utf8_lossy(&output.stderr);
+        anyhow::bail!(
+            "`git {}` failed (exit {})\nstdout: {}\nstderr: {}",
+            args.join(" "),
+            output.status,
+            stdout.trim(),
+            stderr.trim(),
+        );
+    }
+    Ok(())
+}
+
+#[cfg(all(target_os = "macos", feature = "visual-tests"))]
+fn run_start_thread_in_selector_visual_tests(
+    app_state: Arc<AppState>,
+    cx: &mut VisualTestAppContext,
+    update_baseline: bool,
+) -> Result<TestResult> {
+    use agent_ui::{AgentPanel, StartThreadIn, WorktreeCreationStatus};
+
+    // Enable feature flags so the thread target selector renders
+    cx.update(|cx| {
+        cx.update_flags(
+            true,
+            vec!["agent-v2".to_string(), "agent-git-worktrees".to_string()],
+        );
+    });
+
+    // Create a temp directory with a real git repo so "New Worktree" is enabled
+    let temp_dir = tempfile::tempdir()?;
+    let temp_path = temp_dir.keep();
+    let canonical_temp = temp_path.canonicalize()?;
+    let project_path = canonical_temp.join("project");
+    std::fs::create_dir_all(&project_path)?;
+
+    // Initialize git repo
+    run_git_command(&["init"], &project_path)?;
+    run_git_command(&["config", "user.email", "test@test.com"], &project_path)?;
+    run_git_command(&["config", "user.name", "Test User"], &project_path)?;
+
+    // Create source files
+    let src_dir = project_path.join("src");
+    std::fs::create_dir_all(&src_dir)?;
+    std::fs::write(
+        src_dir.join("main.rs"),
+        r#"fn main() {
+    println!("Hello, world!");
+
+    let x = 42;
+    let y = x * 2;
+
+    if y > 50 {
+        println!("y is greater than 50");
+    } else {
+        println!("y is not greater than 50");
+    }
+
+    for i in 0..10 {
+        println!("i = {}", i);
+    }
+}
+
+fn helper_function(a: i32, b: i32) -> i32 {
+    a + b
+}
+"#,
+    )?;
+
+    std::fs::write(
+        project_path.join("Cargo.toml"),
+        r#"[package]
+name = "test_project"
+version = "0.1.0"
+edition = "2021"
+"#,
+    )?;
+
+    // Commit so git status is clean
+    run_git_command(&["add", "."], &project_path)?;
+    run_git_command(&["commit", "-m", "Initial commit"], &project_path)?;
+
+    let project = cx.update(|cx| {
+        project::Project::local(
+            app_state.client.clone(),
+            app_state.node_runtime.clone(),
+            app_state.user_store.clone(),
+            app_state.languages.clone(),
+            app_state.fs.clone(),
+            None,
+            project::LocalProjectFlags {
+                init_worktree_trust: false,
+                ..Default::default()
+            },
+            cx,
+        )
+    });
+
+    // Use a wide window so we see project panel + editor + agent panel
+    let window_size = size(px(1280.0), px(800.0));
+    let bounds = Bounds {
+        origin: point(px(0.0), px(0.0)),
+        size: window_size,
+    };
+
+    let workspace_window: WindowHandle<MultiWorkspace> = cx
+        .update(|cx| {
+            cx.open_window(
+                WindowOptions {
+                    window_bounds: Some(WindowBounds::Windowed(bounds)),
+                    focus: false,
+                    show: false,
+                    ..Default::default()
+                },
+                |window, cx| {
+                    let workspace = cx.new(|cx| {
+                        Workspace::new(None, project.clone(), app_state.clone(), window, cx)
+                    });
+                    cx.new(|cx| MultiWorkspace::new(workspace, window, cx))
+                },
+            )
+        })
+        .context("Failed to open thread target selector test window")?;
+
+    cx.run_until_parked();
+
+    // Create and register the workspace sidebar
+    let sidebar = workspace_window
+        .update(cx, |_multi_workspace, window, cx| {
+            let multi_workspace_handle = cx.entity();
+            cx.new(|cx| sidebar::Sidebar::new(multi_workspace_handle, window, cx))
+        })
+        .context("Failed to create sidebar")?;
+
+    workspace_window
+        .update(cx, |multi_workspace, window, cx| {
+            multi_workspace.register_sidebar(sidebar.clone(), window, cx);
+        })
+        .context("Failed to register sidebar")?;
+
+    // Open the sidebar
+    workspace_window
+        .update(cx, |multi_workspace, window, cx| {
+            multi_workspace.toggle_sidebar(window, cx);
+        })
+        .context("Failed to toggle sidebar")?;
+
+    cx.run_until_parked();
+
+    // Add the git project as a worktree
+    let add_worktree_task = workspace_window
+        .update(cx, |multi_workspace, _window, cx| {
+            let workspace = &multi_workspace.workspaces()[0];
+            let project = workspace.read(cx).project().clone();
+            project.update(cx, |project, cx| {
+                project.find_or_create_worktree(&project_path, true, cx)
+            })
+        })
+        .context("Failed to start adding worktree")?;
+
+    cx.background_executor.allow_parking();
+    cx.foreground_executor
+        .block_test(add_worktree_task)
+        .context("Failed to add worktree")?;
+    cx.background_executor.forbid_parking();
+
+    cx.run_until_parked();
+
+    // Wait for worktree scan and git status
+    for _ in 0..5 {
+        cx.advance_clock(Duration::from_millis(100));
+        cx.run_until_parked();
+    }
+
+    // Open the project panel
+    let (weak_workspace, async_window_cx) = workspace_window
+        .update(cx, |multi_workspace, window, cx| {
+            let workspace = &multi_workspace.workspaces()[0];
+            (workspace.read(cx).weak_handle(), window.to_async(cx))
+        })
+        .context("Failed to get workspace handle")?;
+
+    cx.background_executor.allow_parking();
+    let project_panel = cx
+        .foreground_executor
+        .block_test(ProjectPanel::load(weak_workspace, async_window_cx))
+        .context("Failed to load project panel")?;
+    cx.background_executor.forbid_parking();
+
+    workspace_window
+        .update(cx, |multi_workspace, window, cx| {
+            let workspace = &multi_workspace.workspaces()[0];
+            workspace.update(cx, |workspace, cx| {
+                workspace.add_panel(project_panel, window, cx);
+                workspace.open_panel::<ProjectPanel>(window, cx);
+            });
+        })
+        .context("Failed to add project panel")?;
+
+    cx.run_until_parked();
+
+    // Open main.rs in the editor
+    let open_file_task = workspace_window
+        .update(cx, |multi_workspace, window, cx| {
+            let workspace = &multi_workspace.workspaces()[0];
+            workspace.update(cx, |workspace, cx| {
+                let worktree = workspace.project().read(cx).worktrees(cx).next();
+                if let Some(worktree) = worktree {
+                    let worktree_id = worktree.read(cx).id();
+                    let rel_path: std::sync::Arc<util::rel_path::RelPath> =
+                        util::rel_path::rel_path("src/main.rs").into();
+                    let project_path: project::ProjectPath = (worktree_id, rel_path).into();
+                    Some(workspace.open_path(project_path, None, true, window, cx))
+                } else {
+                    None
+                }
+            })
+        })
+        .log_err()
+        .flatten();
+
+    if let Some(task) = open_file_task {
+        cx.background_executor.allow_parking();
+        cx.foreground_executor.block_test(task).log_err();
+        cx.background_executor.forbid_parking();
+    }
+
+    cx.run_until_parked();
+
+    // Load the AgentPanel
+    let (weak_workspace, async_window_cx) = workspace_window
+        .update(cx, |multi_workspace, window, cx| {
+            let workspace = &multi_workspace.workspaces()[0];
+            (workspace.read(cx).weak_handle(), window.to_async(cx))
+        })
+        .context("Failed to get workspace handle for agent panel")?;
+
+    let prompt_builder =
+        cx.update(|cx| prompt_store::PromptBuilder::load(app_state.fs.clone(), false, cx));
+
+    // Register an observer so that workspaces created by the worktree creation
+    // flow get AgentPanel and ProjectPanel loaded automatically. Without this,
+    // `workspace.panel::<AgentPanel>(cx)` returns None in the new workspace and
+    // the creation flow's `focus_panel::<AgentPanel>` call is a no-op.
+    let _workspace_observer = cx.update({
+        let prompt_builder = prompt_builder.clone();
+        |cx| {
+            cx.observe_new(move |workspace: &mut Workspace, window, cx| {
+                let Some(window) = window else { return };
+                let prompt_builder = prompt_builder.clone();
+                let panels_task = cx.spawn_in(window, async move |workspace_handle, cx| {
+                    let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone());
+                    let agent_panel =
+                        AgentPanel::load(workspace_handle.clone(), prompt_builder, cx.clone());
+                    if let Ok(panel) = project_panel.await {
+                        workspace_handle
+                            .update_in(cx, |workspace, window, cx| {
+                                workspace.add_panel(panel, window, cx);
+                            })
+                            .log_err();
+                    }
+                    if let Ok(panel) = agent_panel.await {
+                        workspace_handle
+                            .update_in(cx, |workspace, window, cx| {
+                                workspace.add_panel(panel, window, cx);
+                            })
+                            .log_err();
+                    }
+                    anyhow::Ok(())
+                });
+                workspace.set_panels_task(panels_task);
+            })
+        }
+    });
+
+    cx.background_executor.allow_parking();
+    let panel = cx
+        .foreground_executor
+        .block_test(AgentPanel::load(
+            weak_workspace,
+            prompt_builder,
+            async_window_cx,
+        ))
+        .context("Failed to load AgentPanel")?;
+    cx.background_executor.forbid_parking();
+
+    workspace_window
+        .update(cx, |multi_workspace, window, cx| {
+            let workspace = &multi_workspace.workspaces()[0];
+            workspace.update(cx, |workspace, cx| {
+                workspace.add_panel(panel.clone(), window, cx);
+                workspace.open_panel::<AgentPanel>(window, cx);
+            });
+        })
+        .context("Failed to add and open AgentPanel")?;
+
+    cx.run_until_parked();
+
+    // Inject the stub server and open a thread so the toolbar is visible
+    let connection = StubAgentConnection::new();
+    let stub_agent: Rc<dyn AgentServer> = Rc::new(StubAgentServer::new(connection));
+
+    cx.update_window(workspace_window.into(), |_, window, cx| {
+        panel.update(cx, |panel, cx| {
+            panel.open_external_thread_with_server(stub_agent.clone(), window, cx);
+        });
+    })?;
+
+    cx.run_until_parked();
+
+    // ---- Screenshot 1: Default "Local Project" selector (dropdown closed) ----
+    cx.update_window(workspace_window.into(), |_, window, _cx| {
+        window.refresh();
+    })?;
+    cx.run_until_parked();
+
+    let result_default = run_visual_test(
+        "start_thread_in_selector_default",
+        workspace_window.into(),
+        cx,
+        update_baseline,
+    );
+
+    // ---- Screenshot 2: Dropdown open showing menu entries ----
+    cx.update_window(workspace_window.into(), |_, window, cx| {
+        panel.update(cx, |panel, cx| {
+            panel.open_start_thread_in_menu_for_tests(window, cx);
+        });
+    })?;
+    cx.run_until_parked();
+
+    cx.update_window(workspace_window.into(), |_, window, _cx| {
+        window.refresh();
+    })?;
+    cx.run_until_parked();
+
+    let result_open_dropdown = run_visual_test(
+        "start_thread_in_selector_open",
+        workspace_window.into(),
+        cx,
+        update_baseline,
+    );
+
+    // ---- Screenshot 3: "New Worktree" selected (dropdown closed, label changed) ----
+    // First dismiss the dropdown, then change the target so the toolbar label is visible
+    cx.update_window(workspace_window.into(), |_, _window, cx| {
+        panel.update(cx, |panel, cx| {
+            panel.close_start_thread_in_menu_for_tests(cx);
+        });
+    })?;
+    cx.run_until_parked();
+
+    cx.update_window(workspace_window.into(), |_, _window, cx| {
+        panel.update(cx, |panel, cx| {
+            panel.set_start_thread_in_for_tests(StartThreadIn::NewWorktree, cx);
+        });
+    })?;
+    cx.run_until_parked();
+
+    cx.update_window(workspace_window.into(), |_, window, _cx| {
+        window.refresh();
+    })?;
+    cx.run_until_parked();
+
+    let result_new_worktree = run_visual_test(
+        "start_thread_in_selector_new_worktree",
+        workspace_window.into(),
+        cx,
+        update_baseline,
+    );
+
+    // ---- Screenshot 4: "Creating worktree…" status banner ----
+    cx.update_window(workspace_window.into(), |_, _window, cx| {
+        panel.update(cx, |panel, cx| {
+            panel
+                .set_worktree_creation_status_for_tests(Some(WorktreeCreationStatus::Creating), cx);
+        });
+    })?;
+    cx.run_until_parked();
+
+    cx.update_window(workspace_window.into(), |_, window, _cx| {
+        window.refresh();
+    })?;
+    cx.run_until_parked();
+
+    let result_creating = run_visual_test(
+        "worktree_creation_status_creating",
+        workspace_window.into(),
+        cx,
+        update_baseline,
+    );
+
+    // ---- Screenshot 5: Error status banner ----
+    cx.update_window(workspace_window.into(), |_, _window, cx| {
+        panel.update(cx, |panel, cx| {
+            panel.set_worktree_creation_status_for_tests(
+                Some(WorktreeCreationStatus::Error(
+                    "Failed to create worktree: branch already exists".into(),
+                )),
+                cx,
+            );
+        });
+    })?;
+    cx.run_until_parked();
+
+    cx.update_window(workspace_window.into(), |_, window, _cx| {
+        window.refresh();
+    })?;
+    cx.run_until_parked();
+
+    let result_error = run_visual_test(
+        "worktree_creation_status_error",
+        workspace_window.into(),
+        cx,
+        update_baseline,
+    );
+
+    // ---- Screenshot 6: Worktree creation succeeded ----
+    // Clear the error status and re-select New Worktree to ensure a clean state.
+    cx.update_window(workspace_window.into(), |_, _window, cx| {
+        panel.update(cx, |panel, cx| {
+            panel.set_worktree_creation_status_for_tests(None, cx);
+        });
+    })?;
+    cx.run_until_parked();
+
+    cx.update_window(workspace_window.into(), |_, window, cx| {
+        window.dispatch_action(Box::new(StartThreadIn::NewWorktree), cx);
+    })?;
+    cx.run_until_parked();
+
+    // Insert a message into the active thread's message editor and submit.
+    let thread_view = cx
+        .read(|cx| panel.read(cx).as_active_thread_view(cx))
+        .ok_or_else(|| anyhow::anyhow!("No active thread view"))?;
+
+    cx.update_window(workspace_window.into(), |_, window, cx| {
+        let message_editor = thread_view.read(cx).message_editor.clone();
+        message_editor.update(cx, |message_editor, cx| {
+            message_editor.set_message(
+                vec![acp::ContentBlock::Text(acp::TextContent::new(
+                    "Add a CLI flag to set the log level".to_string(),
+                ))],
+                window,
+                cx,
+            );
+            message_editor.send(cx);
+        });
+    })?;
+    cx.run_until_parked();
+
+    // Wait for the full worktree creation flow to complete. The creation status
+    // is cleared to `None` at the very end of the async task, after panels are
+    // loaded, the agent panel is focused, and the new workspace is activated.
+    cx.background_executor.allow_parking();
+    let mut creation_complete = false;
+    for _ in 0..120 {
+        cx.run_until_parked();
+        let status_cleared = cx.read(|cx| {
+            panel
+                .read(cx)
+                .worktree_creation_status_for_tests()
+                .is_none()
+        });
+        let workspace_count = workspace_window.update(cx, |multi_workspace, _window, _cx| {
+            multi_workspace.workspaces().len()
+        })?;
+        if workspace_count == 2 && status_cleared {
+            creation_complete = true;
+            break;
+        }
+        cx.advance_clock(Duration::from_millis(100));
+    }
+    cx.background_executor.forbid_parking();
+
+    if !creation_complete {
+        return Err(anyhow::anyhow!("Worktree creation did not complete"));
+    }
+
+    // The creation flow called `external_thread` on the new workspace's agent
+    // panel, which tried to launch a real agent binary and failed. Replace the
+    // error state by injecting the stub server, and shrink the panel so the
+    // editor content is visible.
+    workspace_window.update(cx, |multi_workspace, window, cx| {
+        let new_workspace = &multi_workspace.workspaces()[1];
+        new_workspace.update(cx, |workspace, cx| {
+            if let Some(new_panel) = workspace.panel::<AgentPanel>(cx) {
+                new_panel.update(cx, |panel, cx| {
+                    panel.set_size(Some(px(480.0)), window, cx);
+                    panel.open_external_thread_with_server(stub_agent.clone(), window, cx);
+                });
+            }
+        });
+    })?;
+    cx.run_until_parked();
+
+    // Type and send a message so the thread target dropdown disappears.
+    let new_panel = workspace_window.update(cx, |multi_workspace, _window, cx| {
+        let new_workspace = &multi_workspace.workspaces()[1];
+        new_workspace.read(cx).panel::<AgentPanel>(cx)
+    })?;
+    if let Some(new_panel) = new_panel {
+        let new_thread_view = cx.read(|cx| new_panel.read(cx).as_active_thread_view(cx));
+        if let Some(new_thread_view) = new_thread_view {
+            cx.update_window(workspace_window.into(), |_, window, cx| {
+                let message_editor = new_thread_view.read(cx).message_editor.clone();
+                message_editor.update(cx, |editor, cx| {
+                    editor.set_message(
+                        vec![acp::ContentBlock::Text(acp::TextContent::new(
+                            "Add a CLI flag to set the log level".to_string(),
+                        ))],
+                        window,
+                        cx,
+                    );
+                    editor.send(cx);
+                });
+            })?;
+            cx.run_until_parked();
+        }
+    }
+
+    cx.update_window(workspace_window.into(), |_, window, _cx| {
+        window.refresh();
+    })?;
+    cx.run_until_parked();
+
+    let result_succeeded = run_visual_test(
+        "worktree_creation_succeeded",
+        workspace_window.into(),
+        cx,
+        update_baseline,
+    );
+
+    // Clean up — drop the workspace observer first so no new panels are
+    // registered on workspaces created during teardown.
+    drop(_workspace_observer);
+
+    workspace_window
+        .update(cx, |multi_workspace, _window, cx| {
+            let workspace = &multi_workspace.workspaces()[0];
+            let project = workspace.read(cx).project().clone();
+            project.update(cx, |project, cx| {
+                let worktree_ids: Vec<_> =
+                    project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
+                for id in worktree_ids {
+                    project.remove_worktree(id, cx);
+                }
+            });
+        })
+        .log_err();
+
+    cx.run_until_parked();
+
+    cx.update_window(workspace_window.into(), |_, window, _cx| {
+        window.remove_window();
+    })
+    .log_err();
+
+    cx.run_until_parked();
+
+    for _ in 0..15 {
+        cx.advance_clock(Duration::from_millis(100));
+        cx.run_until_parked();
+    }
+
+    // Delete the preserved temp directory so visual-test runs don't
+    // accumulate filesystem artifacts.
+    if let Err(err) = std::fs::remove_dir_all(&temp_path) {
+        log::warn!(
+            "failed to clean up visual-test temp dir {}: {err}",
+            temp_path.display()
+        );
+    }
+
+    // Reset feature flags
+    cx.update(|cx| {
+        cx.update_flags(false, vec![]);
+    });
+
+    let results = [
+        ("default", result_default),
+        ("open_dropdown", result_open_dropdown),
+        ("new_worktree", result_new_worktree),
+        ("creating", result_creating),
+        ("error", result_error),
+        ("succeeded", result_succeeded),
+    ];
+
+    let mut has_baseline_update = None;
+    let mut failures = Vec::new();
+
+    for (name, result) in &results {
+        match result {
+            Ok(TestResult::Passed) => {}
+            Ok(TestResult::BaselineUpdated(p)) => {
+                has_baseline_update = Some(p.clone());
+            }
+            Err(e) => {
+                failures.push(format!("{}: {}", name, e));
+            }
+        }
+    }
+
+    if !failures.is_empty() {
+        Err(anyhow::anyhow!(
+            "start_thread_in_selector failures: {}",
+            failures.join("; ")
+        ))
+    } else if let Some(p) = has_baseline_update {
+        Ok(TestResult::BaselineUpdated(p))
+    } else {
+        Ok(TestResult::Passed)
+    }
+}

crates/zed/src/zed.rs 🔗

@@ -496,7 +496,8 @@ pub fn initialize_workspace(
             status_bar.add_right_item(image_info, window, cx);
         });
 
-        initialize_panels(prompt_builder.clone(), window, cx);
+        let panels_task = initialize_panels(prompt_builder.clone(), window, cx);
+        workspace.set_panels_task(panels_task);
         register_actions(app_state.clone(), workspace, window, cx);
 
         workspace.focus_handle(cx).focus(window, cx);
@@ -620,7 +621,7 @@ fn initialize_panels(
     prompt_builder: Arc<PromptBuilder>,
     window: &mut Window,
     cx: &mut Context<Workspace>,
-) {
+) -> Task<anyhow::Result<()>> {
     cx.spawn_in(window, async move |workspace_handle, cx| {
         let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone());
         let outline_panel = OutlinePanel::load(workspace_handle.clone(), cx.clone());
@@ -662,7 +663,6 @@ fn initialize_panels(
 
         anyhow::Ok(())
     })
-    .detach();
 }
 
 fn setup_or_teardown_ai_panel<P: Panel>(
@@ -1103,7 +1103,7 @@ fn register_actions(
                             );
                         },
                     )
-                    .detach();
+                    .detach_and_log_err(cx);
                 }
             }
         })
@@ -5808,7 +5808,15 @@ mod tests {
         //   Window B: workspace for dir3
         let (window_a, _) = cx
             .update(|cx| {
-                Workspace::new_local(vec![dir1.into()], app_state.clone(), None, None, None, cx)
+                Workspace::new_local(
+                    vec![dir1.into()],
+                    app_state.clone(),
+                    None,
+                    None,
+                    None,
+                    true,
+                    cx,
+                )
             })
             .await
             .expect("failed to open first workspace");
@@ -5824,7 +5832,15 @@ mod tests {
 
         let (window_b, _) = cx
             .update(|cx| {
-                Workspace::new_local(vec![dir3.into()], app_state.clone(), None, None, None, cx)
+                Workspace::new_local(
+                    vec![dir3.into()],
+                    app_state.clone(),
+                    None,
+                    None,
+                    None,
+                    true,
+                    cx,
+                )
             })
             .await
             .expect("failed to open third workspace");