Cargo.lock 🔗
@@ -368,6 +368,7 @@ dependencies = [
"fs",
"futures 0.3.31",
"fuzzy",
+ "git",
"gpui",
"gpui_tokio",
"html_to_markdown",
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>
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(-)
@@ -368,6 +368,7 @@ dependencies = [
"fs",
"futures 0.3.31",
"fuzzy",
+ "git",
"gpui",
"gpui_tokio",
"html_to_markdown",
@@ -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
@@ -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())
@@ -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),
@@ -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();
}
@@ -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?;
@@ -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),
});
}
}
@@ -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),
});
}
}
@@ -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 {
@@ -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");
@@ -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))
@@ -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,
@@ -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 {
@@ -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 {
@@ -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| {
@@ -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)
+ }
+}
@@ -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");