diff --git a/assets/settings/default.json b/assets/settings/default.json index 37c06960555011..46e279bac5433f 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1116,6 +1116,7 @@ "tools": { "copy_path": true, "create_directory": true, + "create_thread": true, "delete_path": true, "diagnostics": true, "apply_code_action": true, @@ -1126,6 +1127,7 @@ "find_references": true, "get_code_actions": true, "go_to_definition": true, + "list_agents_and_models": true, "list_directory": true, "move_path": true, "rename_symbol": true, @@ -1144,8 +1146,10 @@ // We don't know which of the context server tools are safe for the "Ask" profile, so we don't enable them by default. // "enable_all_context_servers": true, "tools": { + "create_thread": true, "diagnostics": true, "fetch": true, + "list_agents_and_models": true, "list_directory": true, "find_path": true, "find_references": true, diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 9f8dc9f242e13f..743935b0212783 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -293,6 +293,25 @@ impl LanguageModels { } } +/// Implemented by the UI layer to provide the ability for agent tools to create +/// sibling threads that appear in the agent panel. +/// +/// `agent_ui::AgentPanel` installs an implementation of this trait on the +/// `NativeAgent` when it sets up a connection. Tools in a native-agent thread +/// then discover and use the host via `NativeThreadEnvironment`. The UI side +/// is responsible for keeping the installed host current; a host whose +/// backing UI has been torn down will fail its first request with a clear +/// error rather than being detected up front. +pub trait SiblingThreadHost { + fn create_sibling_thread( + &self, + request: SiblingThreadRequest, + cx: &mut AsyncApp, + ) -> Task>; + + fn list_available_agents(&self, cx: &mut App) -> Result; +} + pub struct NativeAgent { /// Session ID -> Session mapping sessions: HashMap, @@ -304,6 +323,8 @@ pub struct NativeAgent { templates: Arc, /// Cached model information models: LanguageModels, + /// Handler installed by the UI for `create_thread` / `list_agents_and_models` tools. + sibling_thread_host: Option>, fs: Arc, _subscriptions: Vec, /// Tracks the lifecycle of global skills directory observation. We @@ -372,6 +393,7 @@ impl NativeAgent { projects: HashMap::default(), templates, models: LanguageModels::new(cx), + sibling_thread_host: None, fs, _subscriptions: subscriptions, skills_state: SkillsState::default(), @@ -498,6 +520,14 @@ impl NativeAgent { } } + pub fn set_sibling_thread_host(&mut self, host: Rc) { + self.sibling_thread_host = Some(host); + } + + pub fn sibling_thread_host(&self) -> Option> { + self.sibling_thread_host.clone() + } + fn new_session( &mut self, project: Entity, @@ -2693,6 +2723,40 @@ impl ThreadEnvironment for NativeThreadEnvironment { ) -> Result> { self.resume_subagent_thread(session_id, cx) } + + fn create_sibling_thread( + &self, + request: SiblingThreadRequest, + cx: &mut AsyncApp, + ) -> Task> { + let host = match self + .agent + .read_with(cx, |agent, _| agent.sibling_thread_host()) + { + Ok(Some(host)) => host, + Ok(None) => { + return Task::ready(Err(anyhow!( + "No sibling-thread host is registered. This usually means the \ + agent panel hasn't been initialized in this workspace." + ))); + } + Err(err) => return Task::ready(Err(err)), + }; + host.create_sibling_thread(request, cx) + } + + fn list_available_agents(&self, cx: &mut App) -> Result { + let host = self + .agent + .read_with(cx, |agent, _| agent.sibling_thread_host())? + .ok_or_else(|| { + anyhow!( + "No sibling-thread host is registered. This usually means the \ + agent panel hasn't been initialized in this workspace." + ) + })?; + host.list_available_agents(cx) + } } #[derive(Debug, Clone)] diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 16faa56c786484..8ae6de6fdb17db 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -5964,6 +5964,121 @@ async fn test_lsp_tools_gated_by_feature_flag(cx: &mut TestAppContext) { ); } +#[gpui::test] +async fn test_sibling_thread_tools_gated_by_feature_flag(cx: &mut TestAppContext) { + init_test(cx); + + // `CreateThreadToolFeatureFlag::enabled_for_staff()` returns true, which + // means tests in debug builds resolve it to ON unless we explicitly + // override it via `FeatureFlagsSettings`. Register the settings type and + // install an (empty) `FeatureFlagStore` global so the `cx.has_flag` path + // actually consults overrides instead of falling back to the + // staff-debug-build default. + cx.update(|cx| { + SettingsStore::update_global(cx, |store, _| { + store.register_setting::(); + }); + cx.update_flags(false, vec![]); + }); + + fn set_flag_override(value: &str, cx: &mut TestAppContext) { + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |content| { + content + .feature_flags + .get_or_insert_default() + .insert("create-thread-tool".to_string(), value.to_string()); + }); + }); + }); + } + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/test"), json!({})).await; + let project = Project::test(fs, [path!("/test").as_ref()], cx).await; + let project_context = cx.new(|_cx| ProjectContext::default()); + let context_server_store = project.read_with(cx, |project, _| project.context_server_store()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let environment = Rc::new(cx.update(|cx| { + FakeThreadEnvironment::default().with_terminal(FakeTerminalHandle::new_never_exits(cx)) + })); + + let thread = cx.new(|cx| { + let mut thread = Thread::new( + project, + project_context, + context_server_registry, + Templates::new(), + Some(model.clone() as Arc), + cx, + ); + thread.add_default_tools(environment, cx); + thread + }); + + let sibling_tool_names = [CreateThreadTool::NAME, ListAgentsAndModelsTool::NAME]; + + // Like the LSP/rename tools, sibling-thread tools are registered + // unconditionally and gated only at exposure time. The registration must + // be visible regardless of the flag's current value. + thread.read_with(cx, |thread, _| { + for name in &sibling_tool_names { + assert!( + thread.has_registered_tool(name), + "expected sibling-thread tool {name} to be registered" + ); + } + }); + + // Flag explicitly off: a completion request must omit the tools. + set_flag_override("off", cx); + thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["hello"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + let completion = model.pending_completions().pop().unwrap(); + let tool_names = tool_names_for_completion(&completion); + for name in &sibling_tool_names { + assert!( + !tool_names.iter().any(|t| t == name), + "expected {name} to be hidden when create-thread-tool flag is off, \ + but completion tools were: {tool_names:?}" + ); + } + // Sanity check: an unrelated default tool should still be exposed. + assert!( + tool_names.iter().any(|t| t == ReadFileTool::NAME), + "expected non-sibling-thread tools to still be exposed, got: {tool_names:?}" + ); + model.end_last_completion_stream(); + cx.run_until_parked(); + + // Flag explicitly on: the next completion request must include both tools. + set_flag_override("on", cx); + thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["hello again"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + let completion = model.pending_completions().pop().unwrap(); + let tool_names = tool_names_for_completion(&completion); + for name in &sibling_tool_names { + assert!( + tool_names.iter().any(|t| t == name), + "expected {name} to be exposed when create-thread-tool flag is on, \ + but completion tools were: {tool_names:?}" + ); + } +} + #[gpui::test] async fn test_parent_cancel_stops_subagent(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 4a5efc2e1cc313..414f52f43f88e7 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -1,17 +1,18 @@ use crate::{ ApplyCodeActionTool, CodeActionStore, ContextServerRegistry, CopyPathTool, CreateDirectoryTool, - DbLanguageModel, DbThread, DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, - FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool, - ListDirectoryTool, MovePathTool, ProjectSnapshot, ReadFileTool, RenameTool, SpawnAgentTool, - SystemPromptTemplate, Template, Templates, TerminalTool, ToolPermissionDecision, - UpdatePlanTool, UpdateTitleTool, WebSearchTool, WriteFileTool, decide_permission_from_settings, + CreateThreadTool, DbLanguageModel, DbThread, DeletePathTool, DiagnosticsTool, EditFileTool, + FetchTool, FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool, + ListAgentsAndModelsTool, ListDirectoryTool, MovePathTool, ProjectSnapshot, ReadFileTool, + RenameTool, SpawnAgentTool, SystemPromptTemplate, Template, Templates, TerminalTool, + ToolPermissionDecision, UpdatePlanTool, UpdateTitleTool, WebSearchTool, WriteFileTool, + decide_permission_from_settings, }; use acp_thread::{MentionUri, UserMessageId}; use action_log::ActionLog; use agent_settings::UserAgentsMd; use feature_flags::{ - FeatureFlagAppExt as _, LspToolFeatureFlag, RenameToolFeatureFlag, UpdatePlanToolFeatureFlag, - UpdateTitleToolFeatureFlag, + CreateThreadToolFeatureFlag, FeatureFlagAppExt as _, LspToolFeatureFlag, RenameToolFeatureFlag, + UpdatePlanToolFeatureFlag, UpdateTitleToolFeatureFlag, }; use agent_client_protocol::schema as acp; @@ -675,6 +676,97 @@ pub trait ThreadEnvironment { "Resuming subagent sessions is not supported" )) } + + /// Creates an independent sibling thread visible in the agent sidebar. + /// Unlike subagents, sibling threads are first-class threads that persist + /// and run in parallel without reporting results back to the parent. + fn create_sibling_thread( + &self, + request: SiblingThreadRequest, + cx: &mut AsyncApp, + ) -> Task> { + let _ = request; + let _ = cx; + Task::ready(Err(anyhow::anyhow!( + "Creating sibling threads is not supported in this environment" + ))) + } + + /// Lists the agents and models available for use with `create_sibling_thread`. + fn list_available_agents(&self, cx: &mut App) -> Result { + let _ = cx; + Err(anyhow::anyhow!( + "Listing available agents is not supported in this environment" + )) + } +} + +/// A request to create a new sibling thread. +#[derive(Debug, Clone)] +pub struct SiblingThreadRequest { + /// A short title for the new thread, shown in the sidebar. + pub title: SharedString, + /// The initial prompt to send to the new thread. + pub prompt: String, + /// Optional agent ID to use. Defaults to the native Zed agent. + pub agent_id: Option, + /// Optional model override, as `provider/model-id`. + /// Defaults to the user's configured default model for the agent. + pub model: Option, + /// Whether to create the thread in a new git worktree workspace. + pub use_new_worktree: bool, + /// Optional worktree directory name. When `None`, the UI generates a + /// random non-colliding name (matching the manual "Create worktree" + /// flow). Only relevant when `use_new_worktree` is true. + pub worktree_name: Option, + /// Git ref (branch, tag, or commit) to base the new worktree on. + /// Only relevant when `use_new_worktree` is true. + pub base_ref: Option, +} + +/// Information returned when a sibling thread is successfully created. +#[derive(Debug, Clone)] +pub struct SiblingThreadInfo { + /// The title assigned to the thread. + pub title: SharedString, + /// The agent ID used for the thread. + pub agent_id: String, + /// The model ID used for the thread, if known. + pub model: Option, + /// An optional, non-fatal heads-up about the created thread that the + /// caller should relay or take into account (e.g., the project had an + /// unusual worktree layout that affected how the new worktree was set + /// up). Empty when nothing noteworthy happened. + pub warning: Option, +} + +/// A list of agents and, for each, the models available for use. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AvailableAgents { + pub agents: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AvailableAgent { + /// Identifier used when creating a thread. + pub id: String, + /// Human-readable name shown in the UI. + pub name: SharedString, + /// Whether this is Zed's built-in native agent. + pub is_native: bool, + /// Models available for this agent. May be empty if models are not + /// enumerated up front (e.g., external agents that choose their own). + pub models: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AvailableModel { + /// Identifier to pass as the `model` field when creating a thread. + pub id: String, + /// Human-readable name. + pub name: SharedString, + /// Whether this is the default model for the agent. + pub is_default: bool, } #[derive(Debug)] @@ -1735,8 +1827,16 @@ impl Thread { self.add_tool(RenameTool::new(self.project.clone())); if self.depth() < MAX_SUBAGENT_DEPTH { - self.add_tool(SpawnAgentTool::new(environment)); + self.add_tool(SpawnAgentTool::new(environment.clone())); } + + // Sibling-thread tools are exposed at every depth: a subagent should + // still be able to kick off independent sibling work on behalf of the + // user, even when it can no longer nest further subagents. Visibility + // to the model is gated by `CreateThreadToolFeatureFlag` in + // `Thread::enabled_tools`. + self.add_tool(CreateThreadTool::new(environment.clone())); + self.add_tool(ListAgentsAndModelsTool::new(environment)); } pub fn add_tool(&mut self, tool: T) { @@ -3062,6 +3162,9 @@ impl Thread { | GetCodeActionsTool::NAME | ApplyCodeActionTool::NAME | GoToDefinitionTool::NAME => cx.has_flag::(), + CreateThreadTool::NAME | ListAgentsAndModelsTool::NAME => { + cx.has_flag::() + } _ => true, }) .collect::>(); diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index 187ce7f6578f85..282d55314937f9 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -2,6 +2,7 @@ mod apply_code_action_tool; mod context_server_registry; mod copy_path_tool; mod create_directory_tool; +mod create_thread_tool; mod delete_path_tool; mod diagnostics_tool; mod edit_file_tool; @@ -14,6 +15,7 @@ mod find_references_tool; mod get_code_actions_tool; mod go_to_definition_tool; mod grep_tool; +mod list_agents_and_models_tool; mod list_directory_tool; mod move_path_tool; mod read_file_tool; @@ -62,6 +64,7 @@ pub use apply_code_action_tool::*; pub use context_server_registry::*; pub use copy_path_tool::*; pub use create_directory_tool::*; +pub use create_thread_tool::*; pub use delete_path_tool::*; pub use diagnostics_tool::*; pub use edit_file_tool::*; @@ -71,6 +74,7 @@ pub use find_references_tool::*; pub use get_code_actions_tool::*; pub use go_to_definition_tool::*; pub use grep_tool::*; +pub use list_agents_and_models_tool::*; pub use list_directory_tool::*; pub use move_path_tool::*; pub use read_file_tool::*; @@ -153,10 +157,23 @@ macro_rules! tools { }; } +// Adding a tool here (and constructing it in `Thread::add_default_tools`) is +// not enough to make the model actually receive it. Two further gates will +// silently drop the tool rather than fail to compile: +// +// 1. `assets/settings/default.json`: the `write` and `ask` agent profiles each +// carry an explicit `tools` allowlist. `Thread::enabled_tools` filters out +// any tool not present there with value `true`, so it never reaches the +// model. +// 2. `test_all_tools_are_in_tool_info_or_excluded` in +// `crates/settings_ui/src/pages/tool_permissions_setup.rs`: every tool must +// be in the permission-UI `TOOLS` list (if it calls +// `decide_permission_from_settings`) or in `EXCLUDED_TOOLS`. tools! { ApplyCodeActionTool, CopyPathTool, CreateDirectoryTool, + CreateThreadTool, DeletePathTool, DiagnosticsTool, EditFileTool, @@ -166,6 +183,7 @@ tools! { GetCodeActionsTool, GoToDefinitionTool, GrepTool, + ListAgentsAndModelsTool, ListDirectoryTool, MovePathTool, ReadFileTool, diff --git a/crates/agent/src/tools/create_thread_tool.rs b/crates/agent/src/tools/create_thread_tool.rs new file mode 100644 index 00000000000000..9f87412d027973 --- /dev/null +++ b/crates/agent/src/tools/create_thread_tool.rs @@ -0,0 +1,201 @@ +use agent_client_protocol::schema as acp; +use anyhow::Result; +use gpui::{App, SharedString, Task}; +use language_model::LanguageModelToolResultContent; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::rc::Rc; +use std::sync::Arc; + +use crate::{AgentTool, SiblingThreadRequest, ThreadEnvironment, ToolCallEventStream, ToolInput}; + +/// Create a new agent thread that runs in parallel with this one. +/// +/// Use this to kick off separable pieces of work without interrupting the current +/// conversation. The new thread appears in the agent sidebar just like a thread +/// the user created themselves, and runs independently — you will NOT receive +/// its output and you cannot interact with it afterwards. Use `spawn_agent` +/// instead if you need the results back. +/// +/// A successful call returns only the title, agent ID, and model used; there is +/// currently no way to look up or control a sibling thread by session ID. +/// +/// ### When to use +/// - The user asks you to start another thread, investigation, or exploration on the side. +/// - You notice a separable task (refactor, bug fix, investigation) that shouldn't +/// derail the current conversation but is worth pursuing. +/// +/// ### Prompt design +/// The new thread has no access to this conversation's history. Include in `prompt` +/// everything the new agent needs: goals, relevant file paths, constraints, and +/// context. Assume the new thread starts from a blank slate in the same project. +/// +/// ### Agent and model selection +/// - If you don't know what agents or models are available, call `list_agents_and_models`. +/// - For bulk / lightweight work (e.g., spawning many parallel threads), prefer a +/// cheaper / faster model over the default. +/// - Leave `agent` and `model` unset to use the user's current defaults. +/// +/// ### Worktree support +/// Set `use_new_worktree` to true to spawn the sibling inside a brand-new +/// workspace (a new tab) backed by linked git worktrees of each git +/// repository in the current project. This mirrors what the user gets when +/// they manually pick "Create worktree" from the worktree picker. +/// +/// - The new workspace opens in its own tab; switch to it manually to see +/// the sibling's progress. +/// - The new worktrees start in detached HEAD state. Use `base_ref` to base +/// them off a specific branch, tag, or commit; omit it to base off `HEAD`. +/// The agent in the sibling thread can attach to a branch by running +/// `git switch -c ` in its terminal if needed. +/// - `worktree_name` overrides the autogenerated directory name. Omit it to +/// let the editor pick a random non-colliding name. +/// - The project must contain at least one git repository, otherwise the +/// call fails. +/// +/// Use this when the sibling needs to make changes that shouldn't touch the +/// user's current working tree (e.g., risky refactors, parallel experiments, +/// or work the user wants to review independently). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub struct CreateThreadToolInput { + /// Short descriptive title for the new thread, shown in the sidebar + /// (e.g., "Investigate flaky login test"). + pub title: String, + + /// The initial prompt to send to the new thread. Include all the context the + /// new agent needs — files, goals, constraints — because it has no access to + /// the current conversation's history. + pub prompt: String, + + /// Optional agent ID to use. Omit to use the user's currently selected agent. + /// Call `list_agents_and_models` if you need to see what's available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option, + + /// Optional model override as `provider/model-id` (e.g., + /// `anthropic/claude-haiku-4-latest`). Only meaningful for Zed's native + /// agent. Omit to use the user's configured default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + + /// If true, create the thread in a new git worktree rather than sharing + /// the parent's worktree. The project must contain a git repository. + #[serde(default)] + pub use_new_worktree: bool, + + /// Optional name for the new worktree directory. When omitted, the + /// editor generates a random non-colliding name (matching the + /// manual "Create worktree" UI behavior). Only used when + /// `use_new_worktree` is true. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree_name: Option, + + /// Git ref (branch, tag, or commit) to base the new worktree on. Only + /// used when `use_new_worktree` is true. Defaults to `HEAD`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_ref: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum CreateThreadToolOutput { + Success { + title: String, + agent_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + model: Option, + /// A non-fatal heads-up about the created thread (e.g., the project's + /// worktree layout was unusual and the new worktree may not match + /// expectations). Present only when there's something to flag. + #[serde(skip_serializing_if = "Option::is_none")] + warning: Option, + }, + Error { + error: String, + }, +} + +impl From for LanguageModelToolResultContent { + fn from(output: CreateThreadToolOutput) -> Self { + serde_json::to_string(&output) + .unwrap_or_else(|e| format!("Failed to serialize create_thread output: {e}")) + .into() + } +} + +pub struct CreateThreadTool { + environment: Rc, +} + +impl CreateThreadTool { + pub fn new(environment: Rc) -> Self { + Self { environment } + } +} + +impl AgentTool for CreateThreadTool { + type Input = CreateThreadToolInput; + type Output = CreateThreadToolOutput; + + const NAME: &'static str = "create_thread"; + + fn kind() -> acp::ToolKind { + acp::ToolKind::Other + } + + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { + match input { + Ok(i) => format!("Create thread: {}", i.title).into(), + Err(value) => value + .get("title") + .and_then(|v| v.as_str()) + .map(|s| format!("Create thread: {s}").into()) + .unwrap_or_else(|| "Create thread".into()), + } + } + + fn run( + self: Arc, + input: ToolInput, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + cx.spawn(async move |cx| { + let input = input + .recv() + .await + .map_err(|e| CreateThreadToolOutput::Error { + error: format!("Failed to receive tool input: {e}"), + })?; + + let title: SharedString = input.title.clone().into(); + let request = SiblingThreadRequest { + title: title.clone(), + prompt: input.prompt, + agent_id: input.agent, + model: input.model, + use_new_worktree: input.use_new_worktree, + worktree_name: input.worktree_name, + base_ref: input.base_ref, + }; + + let task = self.environment.create_sibling_thread(request, cx); + match task.await { + Ok(info) => Ok(CreateThreadToolOutput::Success { + title: info.title.to_string(), + agent_id: info.agent_id, + model: info.model, + warning: info.warning, + }), + Err(error) => Err(CreateThreadToolOutput::Error { + error: error.to_string(), + }), + } + }) + } +} diff --git a/crates/agent/src/tools/list_agents_and_models_tool.rs b/crates/agent/src/tools/list_agents_and_models_tool.rs new file mode 100644 index 00000000000000..5c9b2b22df4486 --- /dev/null +++ b/crates/agent/src/tools/list_agents_and_models_tool.rs @@ -0,0 +1,78 @@ +use agent_client_protocol::schema as acp; +use anyhow::Result; +use gpui::{App, SharedString, Task}; +use language_model::LanguageModelToolResultContent; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::rc::Rc; +use std::sync::Arc; + +use crate::{AgentTool, AvailableAgents, ThreadEnvironment, ToolCallEventStream, ToolInput}; + +/// List the agents and models available for use with the `create_thread` tool. +/// +/// Call this before `create_thread` if you need to pick a specific agent or a +/// non-default model (for example, to use a cheaper model for bulk work). If +/// you're happy with the user's current defaults, you don't need to call this. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub struct ListAgentsAndModelsToolInput {} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ListAgentsAndModelsToolOutput { + Success(AvailableAgents), + Error { error: String }, +} + +impl From for LanguageModelToolResultContent { + fn from(output: ListAgentsAndModelsToolOutput) -> Self { + serde_json::to_string(&output) + .unwrap_or_else(|e| format!("Failed to serialize list_agents_and_models output: {e}")) + .into() + } +} + +pub struct ListAgentsAndModelsTool { + environment: Rc, +} + +impl ListAgentsAndModelsTool { + pub fn new(environment: Rc) -> Self { + Self { environment } + } +} + +impl AgentTool for ListAgentsAndModelsTool { + type Input = ListAgentsAndModelsToolInput; + type Output = ListAgentsAndModelsToolOutput; + + const NAME: &'static str = "list_agents_and_models"; + + fn kind() -> acp::ToolKind { + acp::ToolKind::Other + } + + fn initial_title( + &self, + _input: Result, + _cx: &mut App, + ) -> SharedString { + "List agents and models".into() + } + + fn run( + self: Arc, + _input: ToolInput, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let result = self.environment.list_available_agents(cx); + Task::ready(match result { + Ok(agents) => Ok(ListAgentsAndModelsToolOutput::Success(agents)), + Err(error) => Err(ListAgentsAndModelsToolOutput::Error { + error: error.to_string(), + }), + }) + } +} diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index acabc22a95c6c1..62ff6ac2140c85 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -57,6 +57,7 @@ file_icons.workspace = true fs.workspace = true futures.workspace = true git.workspace = true +git_ui.workspace = true fuzzy.workspace = true gpui.workspace = true gpui_tokio.workspace = true @@ -125,7 +126,6 @@ clock = { workspace = true, features = ["test-support"] } db = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } eval_utils.workspace = true -git_ui.workspace = true gpui = { workspace = true, "features" = ["test-support"] } http_client = { workspace = true, features = ["test-support"] } indoc.workspace = true diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 45aa0014b1aed8..ad2bff5348aa56 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1,4 +1,5 @@ use std::{ + cell::Cell, fmt, path::PathBuf, rc::Rc, @@ -49,7 +50,9 @@ use crate::{ OpenAgentDiff, ResetFastModeWarnings, ResetTrialEndUpsell, ResetTrialUpsell, ShowAllSidebarThreadMetadata, ShowThreadMetadata, ToggleNewThreadMenu, ToggleOptionsMenu, agent_configuration::{AgentConfiguration, AssistantConfigurationEvent}, - conversation_view::{AcpThreadViewEvent, ThreadView, reset_fast_mode_warnings}, + conversation_view::{ + AcpThreadViewEvent, RootThreadUpdated, ThreadView, reset_fast_mode_warnings, + }, ui::{AgentNotification, AgentNotificationEvent, EndTrialUpsell}, }; use crate::{ @@ -58,7 +61,7 @@ use crate::{ }; use agent_settings::AgentSettings; use ai_onboarding::AgentPanelOnboarding; -use anyhow::Result; +use anyhow::{Context as _, Result, anyhow}; #[cfg(feature = "audio")] use audio::{Audio, Sound}; use chrono::{DateTime, Utc}; @@ -67,6 +70,7 @@ use cloud_api_types::Plan; use collections::HashMap; use editor::{Editor, MultiBuffer}; use extension_host::ExtensionStore; +use feature_flags::{CreateThreadToolFeatureFlag, FeatureFlagAppExt as _}; use fs::Fs; use gpui::{ @@ -862,6 +866,26 @@ fn thread_metadata_to_debug_json( }) } +/// Optional parameters for `AgentPanel::create_thread_with_options`. All +/// fields default to the panel's current selection so the agent tool only +/// needs to override what it actually cares about. +#[derive(Default)] +pub struct CreateThreadOptions { + /// Title to assign to the new thread up front. + pub title: Option, + /// Initial content to populate in the thread (optionally auto-submitted). + pub initial_content: Option, + /// Agent to use. Defaults to the panel's selected agent. + pub agent: Option, + /// Model override, as `provider/model-id`. Only applied when the thread + /// uses the native Zed agent. + pub model: Option, + /// Working directories to attach to the new thread (e.g., the path of a + /// freshly-created sibling worktree). When `None`, the thread inherits + /// the project's default path list. + pub work_dirs: Option, +} + pub(crate) struct AgentThread { conversation_view: Entity, } @@ -1794,6 +1818,7 @@ impl AgentPanel { Some(metadata.folder_paths().clone()), metadata.title.clone(), initial_content, + None, AgentThreadSource::AgentPanel, window, cx, @@ -2797,6 +2822,7 @@ impl AgentPanel { None, None, None, + None, source, window, cx, @@ -2964,6 +2990,61 @@ impl AgentPanel { self.serialize(cx); } + /// Creates a new retained thread and inserts it into the sidebar without + /// switching the active view to it. Used by the `create_thread` agent tool, + /// which passes an initial prompt, and optionally an agent and model + /// override. + pub fn create_thread_with_options( + &mut self, + options: CreateThreadOptions, + source: AgentThreadSource, + window: &mut Window, + cx: &mut Context, + ) -> ThreadId { + let (agent, override_used) = if self.project.read(cx).is_via_collab() { + (Agent::NativeAgent, false) + } else if let Some(override_agent) = options.agent { + (override_agent, true) + } else { + (self.selected_agent.clone(), false) + }; + // If the caller explicitly overrode the agent (e.g., the `create_thread` + // tool wants to spawn a sibling thread using a specific agent), we + // shouldn't let that change the panel's selected_agent or the + // last-used-agent preference. Snapshot and restore both. + let saved_selected_agent = override_used.then(|| self.selected_agent.clone()); + let thread = self.create_agent_thread_with_server( + agent, + None, + None, + options.work_dirs, + options.title.clone(), + options.initial_content, + options.model, + source, + window, + cx, + ); + if let Some(original) = saved_selected_agent { + if self.selected_agent != original { + self.selected_agent = original.clone(); + self.serialize(cx); + // Restore the last-used-agent in persistent storage as well. + cx.background_spawn({ + let kvp = KeyValueStore::global(cx); + async move { + write_global_last_used_agent(kvp, original).await; + } + }) + .detach(); + } + } + let thread_id = thread.conversation_view.read(cx).thread_id; + self.retained_threads + .insert(thread_id, thread.conversation_view); + thread_id + } + pub fn activate_retained_thread( &mut self, id: ThreadId, @@ -3217,6 +3298,7 @@ impl AgentPanel { work_dirs, title, initial_content, + None, source, window, cx, @@ -4268,6 +4350,7 @@ impl AgentPanel { work_dirs: Option, title: Option, initial_content: Option, + model_override: Option, source: AgentThreadSource, window: &mut Window, cx: &mut Context, @@ -4284,6 +4367,7 @@ impl AgentPanel { work_dirs, title, initial_content, + model_override, source, window, cx, @@ -4318,6 +4402,7 @@ impl AgentPanel { work_dirs, title, initial_content, + None, source, window, cx, @@ -4333,6 +4418,7 @@ impl AgentPanel { work_dirs: Option, title: Option, initial_content: Option, + model_override: Option, source: AgentThreadSource, window: &mut Window, cx: &mut Context, @@ -4384,23 +4470,74 @@ impl AgentPanel { ) }); - cx.observe(&conversation_view, |this, server_view, cx| { - let is_active = this - .active_conversation_view() - .is_some_and(|active| active.entity_id() == server_view.entity_id()); - if is_active { - cx.emit(AgentPanelEvent::ActiveViewChanged); - this.serialize(cx); - } else { - cx.emit(AgentPanelEvent::EntryChanged); - } - cx.notify(); - }) + cx.observe_in( + &conversation_view, + window, + |this, server_view, window, cx| { + let is_active = this + .active_conversation_view() + .is_some_and(|active| active.entity_id() == server_view.entity_id()); + if is_active { + cx.emit(AgentPanelEvent::ActiveViewChanged); + this.serialize(cx); + } else { + cx.emit(AgentPanelEvent::EntryChanged); + } + this.ensure_sibling_host_installed(&server_view, window, cx); + cx.notify(); + }, + ) .detach(); + // Try installing the host eagerly as well, in case the connection is + // already established by the time the observe fires. + self.ensure_sibling_host_installed(&conversation_view, window, cx); + + if let Some(model) = model_override { + // The native thread is constructed asynchronously after the + // connection establishes. Wait for the first `RootThreadUpdated` + // event that yields a native thread, then apply the override once. + let applied = Cell::new(false); + cx.subscribe( + &conversation_view, + move |_this, view, _event: &RootThreadUpdated, cx| { + if applied.get() { + return; + } + let Some(native_thread) = view.read(cx).as_native_thread(cx) else { + return; + }; + apply_native_model_override(&native_thread, &model, cx); + applied.set(true); + }, + ) + .detach(); + } + AgentThread { conversation_view } } + fn ensure_sibling_host_installed( + &self, + conversation_view: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + if !cx.has_flag::() { + return; + } + let Some(native_connection) = conversation_view.read(cx).as_native_connection(cx) else { + return; + }; + let host = Rc::new(AgentPanelSiblingHost::new( + cx.weak_entity(), + window.window_handle(), + )) as Rc; + native_connection.0.update(cx, |native_agent, _cx| { + native_agent.set_sibling_thread_host(host); + }); + } + fn active_thread_has_messages(&self, cx: &App) -> bool { self.active_agent_thread(cx) .is_some_and(|thread| !thread.read(cx).entries().is_empty()) @@ -4425,6 +4562,272 @@ impl AgentPanel { } } +/// Apply a `provider/model-id` model override to a freshly-created native thread. +/// Best-effort: logs an error and leaves the default model in place if the +/// string can't be parsed or the model isn't registered. +pub(crate) fn apply_native_model_override( + thread: &Entity, + model_id: &str, + cx: &mut App, +) { + let Some(selected) = parse_provider_slash_model(model_id) else { + log::warn!( + "create_thread: could not parse model override {model_id:?}; expected `provider/model-id`" + ); + return; + }; + let configured = LanguageModelRegistry::global(cx) + .update(cx, |registry, cx| registry.select_model(&selected, cx)); + let Some(configured) = configured else { + log::warn!( + "create_thread: no model registered for {model_id:?}; using thread's default model" + ); + return; + }; + thread.update(cx, |thread, cx| { + thread.set_model(configured.model, cx); + }); +} + +fn parse_provider_slash_model(input: &str) -> Option { + let (provider, model) = input.split_once('/')?; + if provider.is_empty() || model.is_empty() { + return None; + } + Some(language_model::SelectedModel { + provider: language_model::LanguageModelProviderId::from(provider.to_string()), + model: language_model::LanguageModelId::from(model.to_string()), + }) +} + +/// Bridges agent-side `SiblingThreadHost` calls to `AgentPanel`. Constructed +/// and installed on a `NativeAgent` by the agent panel when a native-agent +/// thread is created. +pub(crate) struct AgentPanelSiblingHost { + panel: WeakEntity, + window: gpui::AnyWindowHandle, +} + +impl AgentPanelSiblingHost { + pub(crate) fn new(panel: WeakEntity, window: gpui::AnyWindowHandle) -> Self { + Self { panel, window } + } +} + +impl agent::SiblingThreadHost for AgentPanelSiblingHost { + fn create_sibling_thread( + &self, + request: agent::SiblingThreadRequest, + cx: &mut gpui::AsyncApp, + ) -> Task> { + let panel = self.panel.clone(); + let window = self.window; + cx.spawn(async move |cx| { + let agent_choice = match request.agent_id.as_deref() { + None => None, + Some(id) if id == agent::ZED_AGENT_ID.as_ref() => Some(Agent::NativeAgent), + Some(id) => { + // Reject unknown agent ids up front so the model gets a + // structured error pointing at `list_agents_and_models`, + // rather than a thread that silently fails to launch in + // the user's sidebar. + let known = panel + .read_with(cx, |panel, cx| { + let store = panel.project.read(cx).agent_server_store().clone(); + store + .read(cx) + .external_agents() + .any(|known_id| known_id.0.as_ref() == id) + }) + .unwrap_or(false); + if !known { + return Err(anyhow!( + "Unknown agent id {id:?}. Call `list_agents_and_models` \ + to see the agents available for `create_thread`." + )); + } + Some(Agent::Custom { + id: project::AgentId(id.to_string().into()), + }) + } + }; + + let initial_content = AgentInitialContent::ContentBlock { + blocks: vec![acp::ContentBlock::Text(acp::TextContent::new( + request.prompt.clone(), + ))], + auto_submit: true, + }; + + let title: SharedString = request.title.clone(); + let options = CreateThreadOptions { + title: Some(title.clone()), + initial_content: Some(initial_content), + agent: agent_choice.clone(), + model: request.model.clone(), + work_dirs: None, + }; + + // If the caller asked for a fresh worktree, open a new workspace + // backed by a linked git worktree of each git repo in the parent + // project — the same flow the user gets when they pick "Create + // worktree" from the worktree picker. The sibling thread is then + // created inside the new workspace's agent panel, so it lives + // alongside any threads the user would create there manually. + let mut worktree_warning: Option = None; + let target_panel = if request.use_new_worktree { + let workspace = panel.read_with(cx, |panel, _cx| panel.workspace.clone())?; + let workspace = workspace + .upgrade() + .ok_or_else(|| anyhow!("Source workspace is no longer available"))?; + // The branch target follows the existing UI semantics: when + // `base_ref` is set, treat it as the ref to base off of + // (resolved like `git switch --detach `); otherwise base + // off the current HEAD. Either way the new worktrees are in + // detached HEAD state — the agent can attach to a branch via + // git afterwards. + let branch_target = match request.base_ref.as_ref() { + Some(ref_name) => zed_actions::NewWorktreeBranchTarget::ExistingBranch { + name: ref_name.clone(), + }, + None => zed_actions::NewWorktreeBranchTarget::CurrentBranch, + }; + let action = zed_actions::CreateWorktree { + worktree_name: request.worktree_name.clone(), + branch_target, + }; + let creation = window.update(cx, |_root, window, cx| { + workspace.update(cx, |workspace, cx| { + git_ui::worktree_service::create_worktree_workspace( + workspace, &action, window, None, cx, + ) + }) + })?; + let created = creation + .await + .context("failed to create worktree workspace")?; + // The creation flow tells us when the project had multiple + // worktrees of the same underlying repo, which it consolidates + // into one new worktree — flag it so the calling agent knows + // the result may not reflect every source worktree's state. + if created.consolidated_worktrees { + worktree_warning = Some( + "The project contained multiple worktrees backed by the same git \ + repository, so they were consolidated into a single new worktree. \ + The new thread's worktree is based on one of them and may not \ + reflect the exact state of the others." + .to_string(), + ); + } + // Locate the agent panel on the new workspace. We rely on + // the panel having registered by the time + // `create_worktree_workspace` returns — `open_worktree_workspace` + // explicitly awaits `take_panels_task` and the initial scan. + created + .workspace + .read_with(cx, |workspace, cx| workspace.panel::(cx)) + .ok_or_else(|| anyhow!("new workspace did not register an agent panel"))? + .downgrade() + } else { + panel.clone() + }; + // Both the source panel and any newly-opened worktree workspace + // live in the same OS window (the new workspace is a tab on the + // existing MultiWorkspace), so the original window handle is + // still the right context for the `create_thread_with_options` + // call regardless of which panel ends up the target. + let target_window = window; + + // We deliberately don't wait for the new thread's session to + // become available here: there are currently no agent tools that + // operate on sibling threads by session ID, so requiring one would + // just introduce a race for no benefit. + let resolved_agent_id = target_window.update(cx, |_root, window, cx| { + target_panel.update(cx, |panel, cx| { + panel.create_thread_with_options( + options, + AgentThreadSource::AgentPanel, + window, + cx, + ); + let resolved_agent = agent_choice + .clone() + .unwrap_or_else(|| panel.selected_agent.clone()); + resolved_agent.id() + }) + })??; + + Ok(agent::SiblingThreadInfo { + title, + agent_id: resolved_agent_id.0.to_string(), + model: request.model, + warning: worktree_warning, + }) + }) + } + + fn list_available_agents(&self, cx: &mut App) -> Result { + let panel = self + .panel + .upgrade() + .ok_or_else(|| anyhow!("Agent panel is no longer available"))?; + + let mut agents = Vec::new(); + + // Native Zed agent — always available, and we can enumerate models + // directly from the language model registry. + let native_models = { + let registry = LanguageModelRegistry::read_global(cx); + let default = registry.default_model(); + let mut models = Vec::new(); + for provider in registry.providers() { + if !provider.is_authenticated(cx) { + continue; + } + let provider_id = provider.id(); + for model in provider.provided_models(cx) { + let id = format!("{}/{}", provider_id.0, model.id().0); + let is_default = default + .as_ref() + .map(|cm| cm.provider.id() == provider_id && cm.model.id() == model.id()) + .unwrap_or(false); + models.push(agent::AvailableModel { + id, + name: model.name().0, + is_default, + }); + } + } + models + }; + agents.push(agent::AvailableAgent { + id: agent::ZED_AGENT_ID.to_string(), + name: Agent::NativeAgent.label(), + is_native: true, + models: native_models, + }); + + let project = panel.read(cx).project.clone(); + let agent_server_store = project.read(cx).agent_server_store().clone(); + let store = agent_server_store.read(cx); + for agent_id in store.external_agents() { + let display = store + .agent_display_name(agent_id) + .unwrap_or_else(|| agent_id.0.clone()); + agents.push(agent::AvailableAgent { + id: agent_id.0.to_string(), + name: display, + is_native: false, + // External agents pick their own models dynamically; we don't + // try to enumerate them ahead of time. + models: Vec::new(), + }); + } + + Ok(agent::AvailableAgents { agents }) + } +} + impl Focusable for AgentPanel { fn focus_handle(&self, cx: &App) -> FocusHandle { match self.visible_surface() { @@ -4778,6 +5181,7 @@ impl AgentPanel { None, None, Some(initial_content), + None, AgentThreadSource::AgentPanel, window, cx, @@ -6100,6 +6504,7 @@ impl AgentPanel { None, None, None, + None, AgentThreadSource::AgentPanel, window, cx, @@ -6139,6 +6544,7 @@ impl AgentPanel { None, None, None, + None, AgentThreadSource::AgentPanel, window, cx, @@ -6172,6 +6578,7 @@ impl AgentPanel { None, None, None, + None, AgentThreadSource::AgentPanel, window, cx, @@ -12373,4 +12780,77 @@ mod tests { ); }); } + + #[gpui::test] + async fn test_create_thread_with_options_retains_thread_and_restores_agent( + cx: &mut TestAppContext, + ) { + let (panel, mut cx) = setup_panel(cx).await; + let _stub_connection = + crate::test_support::set_stub_agent_connection(StubAgentConnection::new()); + + // Baseline: panel's selected_agent is the stub. + panel.update(&mut cx, |panel, _cx| { + panel.selected_agent = Agent::Stub; + }); + + // Case 1: no agent override. The new thread should land in + // `retained_threads` and `selected_agent` should be unchanged. + let no_override_id = panel.update_in(&mut cx, |panel, window, cx| { + panel.create_thread_with_options( + CreateThreadOptions::default(), + AgentThreadSource::AgentPanel, + window, + cx, + ) + }); + + panel.read_with(&cx, |panel, _cx| { + assert!( + panel.retained_threads.contains_key(&no_override_id), + "thread created via create_thread_with_options should be retained" + ); + assert_eq!( + panel.selected_agent, + Agent::Stub, + "selected_agent should be unchanged when no agent override is requested" + ); + }); + + // Case 2: an explicit agent override that differs from the panel's + // selection. `create_agent_thread_inner` updates `selected_agent` as a + // side effect; `create_thread_with_options` must restore it so the + // user's last-used agent isn't silently flipped by an agent-initiated + // call. + let override_agent = Agent::Custom { + id: "override-agent".into(), + }; + let override_id = panel.update_in(&mut cx, |panel, window, cx| { + panel.create_thread_with_options( + CreateThreadOptions { + agent: Some(override_agent.clone()), + ..CreateThreadOptions::default() + }, + AgentThreadSource::AgentPanel, + window, + cx, + ) + }); + + panel.read_with(&cx, |panel, _cx| { + assert!( + panel.retained_threads.contains_key(&override_id), + "thread created with an agent override should also be retained" + ); + assert_ne!( + no_override_id, override_id, + "each call should produce a distinct ThreadId" + ); + assert_eq!( + panel.selected_agent, + Agent::Stub, + "selected_agent should be restored to the original after an agent override" + ); + }); + } } diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index f6febdc5c4eca3..36e363ca40cec0 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -71,6 +71,21 @@ impl FeatureFlag for UpdatePlanToolFeatureFlag { } register_feature_flag!(UpdatePlanToolFeatureFlag); +/// Gates the `create_thread` and `list_agents_and_models` tools, which let +/// the agent spawn independent sibling threads that show up in the agent +/// panel sidebar. +pub struct CreateThreadToolFeatureFlag; + +impl FeatureFlag for CreateThreadToolFeatureFlag { + const NAME: &'static str = "create-thread-tool"; + type Value = PresenceFlag; + + fn enabled_for_staff() -> bool { + true + } +} +register_feature_flag!(CreateThreadToolFeatureFlag); + pub struct UpdateTitleToolFeatureFlag; impl FeatureFlag for UpdateTitleToolFeatureFlag { diff --git a/crates/git_ui/src/worktree_service.rs b/crates/git_ui/src/worktree_service.rs index a37b08bac7666e..8c0153c49adcae 100644 --- a/crates/git_ui/src/worktree_service.rs +++ b/crates/git_ui/src/worktree_service.rs @@ -9,7 +9,7 @@ use collections::HashSet; use fs::Fs; use gpui::{ AsyncWindowContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, SharedString, - TaskExt, WeakEntity, + Task, TaskExt, WeakEntity, }; use project::Project; use project::git_store::Repository; @@ -177,7 +177,7 @@ impl Render for WorktreeFetchFailedToast { cx.emit(DismissEvent); if let Some(workspace) = workspace_for_retry.upgrade() { workspace.update(cx, |workspace, cx| { - handle_create_worktree_inner( + let task = create_worktree_workspace_inner( workspace, &zed_actions::CreateWorktree { worktree_name: worktree_name.clone(), @@ -186,8 +186,11 @@ impl Render for WorktreeFetchFailedToast { window, focused_dock, RemoteBranchFetchMode::UseLocal, + // User-initiated retry of a foreground create. + true, cx, ); + task.detach_and_log_err(cx); }); } })), @@ -350,6 +353,14 @@ async fn fetch_remote_for_worktree_base( /// /// - `creation_infos`: a vec of `(repo, new_path, receiver)` tuples. /// - `path_remapping`: `(old_work_dir, new_worktree_path)` pairs for remapping editor tabs. +/// +/// Multiple entries in `git_repos` can be linked worktrees of the *same* +/// underlying repository (e.g. a project that has both the main checkout and +/// one of its linked worktrees open as separate Zed worktrees). Those entries +/// resolve to the same target path via [`Repository::path_for_new_linked_worktree`], +/// so we create the new worktree only once and remap every contributing +/// work directory onto it. Without this dedup, the second `git worktree add` +/// fails with "already exists". fn start_worktree_creations( git_repos: &[Entity], worktree_name: Option, @@ -369,6 +380,7 @@ fn start_worktree_creations( )> { let mut creation_infos = Vec::new(); let mut path_remapping = Vec::new(); + let mut scheduled_paths: HashSet = HashSet::default(); let worktree_name = worktree_name.unwrap_or_else(|| { let existing_refs: Vec<&str> = existing_worktree_names.iter().map(|s| s.as_str()).collect(); @@ -383,15 +395,25 @@ fn start_worktree_creations( if existing_worktree_paths.contains(&new_path) { anyhow::bail!("A worktree already exists at {}", new_path.display()); } - let target = git::repository::CreateWorktreeTarget::Detached { - base_sha: base_ref.clone(), - }; - let receiver = repo.create_worktree(target, new_path.clone()); let work_dir = repo.work_directory_abs_path.clone(); + // Only the first repo that resolves to a given target path + // actually creates the worktree; subsequent linked worktrees of + // the same repository just contribute a path remapping. + let receiver = if scheduled_paths.contains(&new_path) { + None + } else { + let target = git::repository::CreateWorktreeTarget::Detached { + base_sha: base_ref.clone(), + }; + Some(repo.create_worktree(target, new_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)); + if let Some(receiver) = receiver { + scheduled_paths.insert(new_path.clone()); + creation_infos.push((repo.clone(), new_path, receiver)); + } } Ok((creation_infos, path_remapping)) @@ -561,6 +583,9 @@ fn maybe_propagate_worktree_trust( /// Handles the `CreateWorktree` action generically, without any agent panel involvement. /// Creates a new git worktree, opens the workspace, restores layout and files. +/// Errors are surfaced to the user via toasts; the new workspace handle is +/// discarded. Use [`create_worktree_workspace`] when you need the resulting +/// workspace (e.g., the `create_thread` agent tool spawns a thread in it). pub fn handle_create_worktree( workspace: &mut Workspace, action: &zed_actions::CreateWorktree, @@ -568,38 +593,94 @@ pub fn handle_create_worktree( fallback_focused_dock: Option, cx: &mut gpui::Context, ) { - handle_create_worktree_inner( + let task = create_worktree_workspace_inner( workspace, action, window, fallback_focused_dock, RemoteBranchFetchMode::Fetch, + // The user explicitly asked to create a worktree, so foreground it. + true, cx, ); + task.detach_and_log_err(cx); +} + +/// Outcome of [`create_worktree_workspace`]. +pub struct CreatedWorktreeWorkspace { + /// The newly opened workspace. + pub workspace: Entity, + /// True when the project contained more than one Zed worktree backed by + /// the same underlying git repository, so they were consolidated into a + /// single new worktree (they resolve to the same target path). Callers + /// that care — like the `create_thread` agent tool — can use this to warn + /// that the result may not reflect every source worktree's state. + pub consolidated_worktrees: bool, } -fn handle_create_worktree_inner( +/// Same as [`handle_create_worktree`], but returns a `Task` that resolves to +/// the new workspace once worktree creation and post-open setup are +/// complete. The caller receives errors as `Result`s and is expected to +/// handle them. Note that a small set of early failures (no git repositories, +/// disconnected remote, mid-creation `git fetch` failure) still surface a +/// toast on the source workspace so the user understands why the action +/// didn't take effect; the same error is also returned to the caller. +/// +/// Used by the `create_thread` agent tool to spawn a sibling thread inside +/// the newly-opened workspace. +/// +/// The new workspace is opened in the **background** (added as a retained +/// tab without switching to it or moving focus), and it's a clean checkout +/// rather than inheriting the source workspace's open files and dock layout. +/// This mirrors how the agent's non-worktree threads are created in the +/// background rather than yanking the user away from what they're doing. +pub fn create_worktree_workspace( + workspace: &mut Workspace, + action: &zed_actions::CreateWorktree, + window: &mut gpui::Window, + fallback_focused_dock: Option, + cx: &mut gpui::Context, +) -> Task> { + create_worktree_workspace_inner( + workspace, + action, + window, + fallback_focused_dock, + RemoteBranchFetchMode::Fetch, + // Agent-created worktree workspaces open in the background. + false, + cx, + ) +} + +fn create_worktree_workspace_inner( workspace: &mut Workspace, action: &zed_actions::CreateWorktree, window: &mut gpui::Window, fallback_focused_dock: Option, remote_branch_fetch_mode: RemoteBranchFetchMode, + activate: bool, cx: &mut gpui::Context, -) { +) -> Task> { let project = workspace.project().clone(); if project.read(cx).repositories(cx).is_empty() { - log::error!("create_worktree: no git repository in the project"); - return; + return Task::ready(Err(anyhow!( + "create_worktree: no git repository in the project" + ))); } if project.read(cx).is_via_collab() { - log::error!("create_worktree: not supported in collab projects"); - return; + return Task::ready(Err(anyhow!( + "create_worktree: not supported in collab projects" + ))); } - // Guard against concurrent creation + // Guard against concurrent creation. We treat a concurrent creation as + // a hard error here so the caller can surface it; the user-facing + // wrapper [`handle_create_worktree`] swallows the error via + // `detach_and_log_err`, matching the pre-existing silent return. if workspace.active_worktree_creation().label.is_some() { - return; + return Task::ready(Err(anyhow!("A worktree creation is already in progress"))); } let previous_state = @@ -611,13 +692,14 @@ fn handle_create_worktree_inner( let (git_repos, non_git_paths) = classify_worktrees(project.read(cx), cx); if git_repos.is_empty() { + let toast_workspace = cx.entity(); show_error_toast( - cx.entity(), + toast_workspace, "worktree create", anyhow!("No git repositories found in the project"), cx, ); - return; + return Task::ready(Err(anyhow!("No git repositories found in the project"))); } if remote_connection_options.is_some() { @@ -626,13 +708,16 @@ fn handle_create_worktree_inner( .remote_client() .is_some_and(|client| client.read(cx).is_disconnected()); if is_disconnected { + let toast_workspace = cx.entity(); show_error_toast( - cx.entity(), + toast_workspace, "worktree create", anyhow!("Cannot create worktree: remote connection is not active"), cx, ); - return; + return Task::ready(Err(anyhow!( + "Cannot create worktree: remote connection is not active" + ))); } } @@ -677,6 +762,7 @@ fn handle_create_worktree_inner( workspace_handle.clone(), window_handle, remote_connection_options, + activate, &mut cx, ) .await; @@ -707,7 +793,6 @@ fn handle_create_worktree_inner( result }) - .detach_and_log_err(cx); } pub fn handle_switch_worktree( @@ -791,8 +876,9 @@ async fn do_create_worktree( workspace: WeakEntity, window_handle: Option>, remote_connection_options: Option, + activate: bool, cx: &mut AsyncWindowContext, -) -> anyhow::Result<()> { +) -> anyhow::Result { // List existing worktrees from all repos to detect name collisions let worktree_receivers: Vec<_> = cx.update(|_, cx| { git_repos @@ -874,11 +960,17 @@ async fn do_create_worktree( let created_paths = await_and_rollback_on_failure(creation_infos, fs, cx).await?; + // `path_remapping` has one entry per source git repo, while `created_paths` + // has one per *unique* target worktree. When the former is larger, two or + // more source repos were linked worktrees of the same underlying + // repository and `start_worktree_creations` consolidated them. + let consolidated_worktrees = path_remapping.len() > created_paths.len(); + let mut all_paths = created_paths; let has_non_git = !non_git_paths.is_empty(); all_paths.extend(non_git_paths.iter().cloned()); - open_worktree_workspace( + let workspace = open_worktree_workspace( all_paths, path_remapping, non_git_paths, @@ -888,9 +980,15 @@ async fn do_create_worktree( window_handle, remote_connection_options, WorktreeOperation::Create, + activate, cx, ) - .await + .await?; + + Ok(CreatedWorktreeWorkspace { + workspace, + consolidated_worktrees, + }) } async fn do_switch_worktree( @@ -902,7 +1000,7 @@ async fn do_switch_worktree( window_handle: Option>, remote_connection_options: Option, cx: &mut AsyncWindowContext, -) -> anyhow::Result<()> { +) -> anyhow::Result> { let path_remapping: Vec<(PathBuf, PathBuf)> = git_repo_work_dirs .iter() .map(|work_dir| (work_dir.clone(), worktree_path.clone())) @@ -922,12 +1020,16 @@ async fn do_switch_worktree( window_handle, remote_connection_options, WorktreeOperation::Switch, + // Switching is always an explicit, foreground user action. + true, cx, ) .await } /// Core workspace opening logic shared by both create and switch flows. +/// Returns the newly opened workspace entity so callers can do post-open +/// work (e.g., the `create_thread` agent tool spawns a thread inside it). async fn open_worktree_workspace( all_paths: Vec, path_remapping: Vec<(PathBuf, PathBuf)>, @@ -938,8 +1040,9 @@ async fn open_worktree_workspace( window_handle: Option>, remote_connection_options: Option, operation: WorktreeOperation, + activate: bool, cx: &mut AsyncWindowContext, -) -> anyhow::Result<()> { +) -> anyhow::Result> { let window_handle = window_handle .ok_or_else(|| anyhow!("No window handle available for workspace creation"))?; @@ -947,7 +1050,14 @@ async fn open_worktree_workspace( let is_creating_new_worktree = matches!(operation, WorktreeOperation::Create); - let source_for_transfer = if is_creating_new_worktree { + // When `activate` is false the new workspace is opened in the background + // (e.g. the agent's `create_thread` tool), so it should be a clean + // checkout rather than inheriting the source workspace's open files and + // dock layout. The state transfer only applies when we're foregrounding + // a freshly-created worktree for the user. + let transfer_state = is_creating_new_worktree && activate; + + let source_for_transfer = if transfer_state { Some(workspace.clone()) } else { None @@ -964,7 +1074,7 @@ async fn open_worktree_workspace( dyn FnOnce(&mut Workspace, &mut gpui::Window, &mut gpui::Context) + Send, >, - > = if is_creating_new_worktree { + > = if transfer_state { let dock_structure = previous_state.dock_structure; Some(Box::new( move |workspace: &mut Workspace, @@ -1034,7 +1144,7 @@ async fn open_worktree_workspace( maybe_propagate_worktree_trust(&workspace, &new_workspace, &all_paths, cx); - if is_creating_new_worktree { + if transfer_state { window_handle.update(cx, |_multi_workspace, window, cx| { new_workspace.update(cx, |workspace, cx| { if has_non_git { @@ -1133,13 +1243,21 @@ async fn open_worktree_workspace( .ok(); window_handle.update(cx, |multi_workspace, window, cx| { - multi_workspace.activate(new_workspace.clone(), source_for_transfer, window, cx); + if activate { + multi_workspace.activate(new_workspace.clone(), source_for_transfer, window, cx); + } else { + // Background open: register the new workspace as a retained tab + // but leave the user where they are. + multi_workspace.add_background_workspace(new_workspace.clone(), window, cx); + } if is_creating_new_worktree { new_workspace.update(cx, |workspace, cx| { + // Run create-worktree setup hooks regardless of foreground vs + // background — the worktree was created either way. workspace.run_create_worktree_tasks(window, cx); - if let Some(dock_position) = focused_dock { + if activate && let Some(dock_position) = focused_dock { let dock = workspace.dock_at_position(dock_position); if let Some(panel) = dock.read(cx).active_panel() { panel.panel_focus_handle(cx).focus(window, cx); @@ -1149,7 +1267,7 @@ async fn open_worktree_workspace( } })?; - anyhow::Ok(()) + Ok(new_workspace) } #[cfg(test)] diff --git a/crates/settings_ui/src/pages/tool_permissions_setup.rs b/crates/settings_ui/src/pages/tool_permissions_setup.rs index 3122e63d2b6798..8f010bc6b0565f 100644 --- a/crates/settings_ui/src/pages/tool_permissions_setup.rs +++ b/crates/settings_ui/src/pages/tool_permissions_setup.rs @@ -1409,6 +1409,7 @@ mod tests { "get_code_actions", "go_to_definition", "grep", + "list_agents_and_models", "list_directory", "open", "read_file", @@ -1417,8 +1418,9 @@ mod tests { // streaming_edit_file uses "edit_file" for permission lookups, // so its rules are configured under the edit_file entry. "streaming_edit_file", - // Subagent permission checks happen at the level of individual - // tool calls within the subagent, not at the spawning level. + // Sibling/subagent thread creation delegates permission checks to + // tool calls inside the spawned thread, not the spawning itself. + "create_thread", "spawn_agent", // update_plan updates UI-visible planning state but does not use // tool permission rules. diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 61344668eb2a08..ed652ef374589f 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -1504,6 +1504,29 @@ impl MultiWorkspace { cx.notify(); } + /// Adds `workspace` as a retained background tab without switching the + /// active workspace to it or moving focus. Mirrors the registration and + /// retention bookkeeping `activate` performs for the incoming workspace, + /// but leaves the currently-active workspace focused. + /// + /// Used when something opens a workspace the user should not be yanked + /// into — e.g. the agent's `create_thread` tool spawning a sibling + /// worktree in the background. + pub fn add_background_workspace( + &mut self, + workspace: Entity, + window: &mut Window, + cx: &mut Context, + ) { + if self.workspace() == &workspace || self.is_workspace_retained(&workspace) { + return; + } + self.register_workspace(&workspace, window, cx); + let key = workspace.read(cx).project_group_key(cx); + self.retain_workspace(workspace, key, cx); + cx.notify(); + } + /// Promotes the currently active workspace to persistent if it is /// transient, so it is retained across workspace switches even when /// the sidebar is closed. No-op if the workspace is already persistent.