From 3c17c297d839c83df82efb5cc97a85f49ee5fb5a Mon Sep 17 00:00:00 2001 From: Eric Holk Date: Fri, 17 Apr 2026 15:59:56 -0700 Subject: [PATCH 1/4] Add create_thread and list_agents_and_models agent tools Introduces two new built-in agent tools that let an agent spawn independent sibling threads which appear in the agent sidebar and run in parallel with the current conversation. --- assets/settings/default.json | 4 + crates/agent/src/agent.rs | 61 ++++ crates/agent/src/thread.rs | 103 +++++- crates/agent/src/tools.rs | 18 + crates/agent/src/tools/create_thread_tool.rs | 175 ++++++++++ .../src/tools/list_agents_and_models_tool.rs | 78 +++++ crates/agent_ui/src/agent_panel.rs | 318 +++++++++++++++++- .../src/pages/tool_permissions_setup.rs | 6 +- 8 files changed, 742 insertions(+), 21 deletions(-) create mode 100644 crates/agent/src/tools/create_thread_tool.rs create mode 100644 crates/agent/src/tools/list_agents_and_models_tool.rs 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..bc8b95ba8b548c 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -293,6 +293,22 @@ 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`. +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 +320,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 +390,7 @@ impl NativeAgent { projects: HashMap::default(), templates, models: LanguageModels::new(cx), + sibling_thread_host: None, fs, _subscriptions: subscriptions, skills_state: SkillsState::default(), @@ -498,6 +517,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 +2720,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/thread.rs b/crates/agent/src/thread.rs index 4a5efc2e1cc313..02fa7184cb24a9 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -1,10 +1,11 @@ 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; @@ -675,6 +676,90 @@ 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. + /// Not yet supported; passing `true` will return an error. + pub use_new_worktree: bool, + /// 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, Serialize, Deserialize)] +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. + #[serde(skip_serializing_if = "Option::is_none")] + pub model: 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 +1820,14 @@ 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. + self.add_tool(CreateThreadTool::new(environment.clone())); + self.add_tool(ListAgentsAndModelsTool::new(environment)); } pub fn add_tool(&mut self, tool: T) { 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..4c441d8e8c3997 --- /dev/null +++ b/crates/agent/src/tools/create_thread_tool.rs @@ -0,0 +1,175 @@ +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 +/// Not yet implemented. Setting `use_new_worktree` to true currently returns an +/// error. Sibling threads always share the parent thread's worktree for now. +#[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. NOT YET SUPPORTED — passing true currently fails. + #[serde(default)] + pub use_new_worktree: bool, + + /// Git ref to base the new worktree on. Only used when `use_new_worktree` + /// is true. + #[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, + }, + 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}"), + })?; + + if input.use_new_worktree { + return Err(CreateThreadToolOutput::Error { + error: "Creating sibling threads in a new git worktree is not yet supported. \ + Set `use_new_worktree` to false to create the thread in the current worktree." + .into(), + }); + } + + 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, + 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, + }), + 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/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 45aa0014b1aed8..ae0a4e95bc5b0e 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -58,7 +58,7 @@ use crate::{ }; use agent_settings::AgentSettings; use ai_onboarding::AgentPanelOnboarding; -use anyhow::Result; +use anyhow::{Result, anyhow}; #[cfg(feature = "audio")] use audio::{Audio, Sound}; use chrono::{DateTime, Utc}; @@ -862,6 +862,21 @@ fn thread_metadata_to_debug_json( }) } +/// Optional parameters for `AgentPanel::create_thread_with_options`. The +/// default value matches today's behavior of the bare `create_thread` method. +#[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, +} + pub(crate) struct AgentThread { conversation_view: Entity, } @@ -2964,6 +2979,79 @@ 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, + None, + options.title.clone(), + options.initial_content, + 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; + if let Some(model) = options.model.as_ref() { + let model = model.clone(); + let conversation_view = thread.conversation_view.downgrade(); + cx.spawn(async move |_this, cx| { + // The underlying native thread is created asynchronously, so defer + // the model selection until it's available. + cx.background_executor() + .timer(std::time::Duration::from_millis(0)) + .await; + conversation_view + .update(cx, |conversation_view, cx| { + if let Some(native_thread) = conversation_view.as_native_thread(cx) { + apply_native_model_override(&native_thread, &model, cx); + } + }) + .ok(); + }) + .detach(); + } + self.retained_threads + .insert(thread_id, thread.conversation_view); + thread_id + } + pub fn activate_retained_thread( &mut self, id: ThreadId, @@ -4384,23 +4472,54 @@ 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); + AgentThread { conversation_view } } + fn ensure_sibling_host_installed( + &self, + conversation_view: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + let Some(native_connection) = conversation_view.read(cx).as_native_connection(cx) else { + return; + }; + let native_agent = native_connection.0.clone(); + if native_agent.read(cx).sibling_thread_host().is_some() { + return; + } + let host = Rc::new(AgentPanelSiblingHost::new( + cx.weak_entity(), + window.window_handle(), + )) as Rc; + native_agent.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 +4544,179 @@ 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) => 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(), + }; + + // 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 = window.update(cx, |_root, window, cx| { + 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, + }) + }) + } + + 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() { 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. From 285fb870022e045d40464b1ab529ec4458e1a275 Mon Sep 17 00:00:00 2001 From: Eric Holk Date: Fri, 17 Apr 2026 16:22:32 -0700 Subject: [PATCH 2/4] Gate sibling-thread tools behind create-thread-tool feature flag Adds CreateThreadToolFeatureFlag (enabled for staff) and conditionally registers create_thread and list_agents_and_models in Thread::add_default_tools only when the flag is set. --- crates/agent/src/thread.rs | 10 ++++++---- crates/feature_flags/src/flags.rs | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 02fa7184cb24a9..7368577913de62 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -11,8 +11,8 @@ 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; @@ -1826,8 +1826,10 @@ impl Thread { // 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. - self.add_tool(CreateThreadTool::new(environment.clone())); - self.add_tool(ListAgentsAndModelsTool::new(environment)); + if cx.has_flag::() { + self.add_tool(CreateThreadTool::new(environment.clone())); + self.add_tool(ListAgentsAndModelsTool::new(environment)); + } } pub fn add_tool(&mut self, tool: T) { 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 { From 64c0c8be2fd98add728de044425c90ea58467170 Mon Sep 17 00:00:00 2001 From: Eric Holk Date: Tue, 26 May 2026 15:40:19 -0700 Subject: [PATCH 3/4] Improvements from personal code review --- crates/agent/src/agent.rs | 5 +- crates/agent/src/tests/mod.rs | 115 +++++++++++++++++++ crates/agent/src/thread.rs | 16 +-- crates/agent_ui/src/agent_panel.rs | 172 ++++++++++++++++++++++++----- 4 files changed, 270 insertions(+), 38 deletions(-) diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index bc8b95ba8b548c..743935b0212783 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -298,7 +298,10 @@ impl LanguageModels { /// /// `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`. +/// 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, 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 7368577913de62..8466c93349b078 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -722,14 +722,13 @@ pub struct SiblingThreadRequest { } /// Information returned when a sibling thread is successfully created. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[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. - #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, } @@ -1825,11 +1824,11 @@ impl Thread { // 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. - if cx.has_flag::() { - self.add_tool(CreateThreadTool::new(environment.clone())); - self.add_tool(ListAgentsAndModelsTool::new(environment)); - } + // 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) { @@ -3155,6 +3154,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_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index ae0a4e95bc5b0e..2485fbe34b45ab 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::{ @@ -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,8 +866,9 @@ fn thread_metadata_to_debug_json( }) } -/// Optional parameters for `AgentPanel::create_thread_with_options`. The -/// default value matches today's behavior of the bare `create_thread` method. +/// 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. @@ -1809,6 +1814,7 @@ impl AgentPanel { Some(metadata.folder_paths().clone()), metadata.title.clone(), initial_content, + None, AgentThreadSource::AgentPanel, window, cx, @@ -2812,6 +2818,7 @@ impl AgentPanel { None, None, None, + None, source, window, cx, @@ -3009,6 +3016,7 @@ impl AgentPanel { None, options.title.clone(), options.initial_content, + options.model, source, window, cx, @@ -3028,25 +3036,6 @@ impl AgentPanel { } } let thread_id = thread.conversation_view.read(cx).thread_id; - if let Some(model) = options.model.as_ref() { - let model = model.clone(); - let conversation_view = thread.conversation_view.downgrade(); - cx.spawn(async move |_this, cx| { - // The underlying native thread is created asynchronously, so defer - // the model selection until it's available. - cx.background_executor() - .timer(std::time::Duration::from_millis(0)) - .await; - conversation_view - .update(cx, |conversation_view, cx| { - if let Some(native_thread) = conversation_view.as_native_thread(cx) { - apply_native_model_override(&native_thread, &model, cx); - } - }) - .ok(); - }) - .detach(); - } self.retained_threads .insert(thread_id, thread.conversation_view); thread_id @@ -3305,6 +3294,7 @@ impl AgentPanel { work_dirs, title, initial_content, + None, source, window, cx, @@ -4356,6 +4346,7 @@ impl AgentPanel { work_dirs: Option, title: Option, initial_content: Option, + model_override: Option, source: AgentThreadSource, window: &mut Window, cx: &mut Context, @@ -4372,6 +4363,7 @@ impl AgentPanel { work_dirs, title, initial_content, + model_override, source, window, cx, @@ -4406,6 +4398,7 @@ impl AgentPanel { work_dirs, title, initial_content, + None, source, window, cx, @@ -4421,6 +4414,7 @@ impl AgentPanel { work_dirs: Option, title: Option, initial_content: Option, + model_override: Option, source: AgentThreadSource, window: &mut Window, cx: &mut Context, @@ -4495,6 +4489,27 @@ impl AgentPanel { // 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 } } @@ -4504,18 +4519,17 @@ impl AgentPanel { 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 native_agent = native_connection.0.clone(); - if native_agent.read(cx).sibling_thread_host().is_some() { - return; - } let host = Rc::new(AgentPanelSiblingHost::new( cx.weak_entity(), window.window_handle(), )) as Rc; - native_agent.update(cx, |native_agent, _cx| { + native_connection.0.update(cx, |native_agent, _cx| { native_agent.set_sibling_thread_host(host); }); } @@ -4608,9 +4622,30 @@ impl agent::SiblingThreadHost for AgentPanelSiblingHost { 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) => Some(Agent::Custom { - id: project::AgentId(id.to_string().into()), - }), + 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 { @@ -5070,6 +5105,7 @@ impl AgentPanel { None, None, Some(initial_content), + None, AgentThreadSource::AgentPanel, window, cx, @@ -6392,6 +6428,7 @@ impl AgentPanel { None, None, None, + None, AgentThreadSource::AgentPanel, window, cx, @@ -6431,6 +6468,7 @@ impl AgentPanel { None, None, None, + None, AgentThreadSource::AgentPanel, window, cx, @@ -6464,6 +6502,7 @@ impl AgentPanel { None, None, None, + None, AgentThreadSource::AgentPanel, window, cx, @@ -12665,4 +12704,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" + ); + }); + } } From 497bf37ea56bc39ecc265c33a16e0864c3fec4f9 Mon Sep 17 00:00:00 2001 From: Eric Holk Date: Thu, 28 May 2026 11:57:49 -0700 Subject: [PATCH 4/4] Support creating sibling threads in a new git worktree When create_thread is called with use_new_worktree, open a new background workspace backed by linked git worktrees of the project's git repos (reusing the worktree picker's create flow) and spawn the thread there, rather than only sharing the parent's worktree. - Add git_ui::worktree_service::create_worktree_workspace, a Task- returning variant of handle_create_worktree that hands back the new workspace so the agent tool can create a thread in it. - Open the agent's worktree workspace in the background via a new MultiWorkspace::add_background_workspace, leaving the user where they are and giving the new workspace a clean checkout (no inherited open files or dock layout). This matches how non-worktree sibling threads are created in the background. - Dedup linked worktrees of the same underlying repo in start_worktree_creations so they don't collide on the same target path (previously a hard 'already exists' failure for both this flow and the manual Create worktree UI). - Report a non-fatal warning back to the calling agent when the project had multiple worktrees of the same repo and they were consolidated. - Add optional worktree_name to the create_thread tool input. Release Notes: - N/A --- crates/agent/src/thread.rs | 12 +- crates/agent/src/tools/create_thread_tool.rs | 60 ++++-- crates/agent_ui/Cargo.toml | 2 +- crates/agent_ui/src/agent_panel.rs | 84 ++++++++- crates/git_ui/src/worktree_service.rs | 182 +++++++++++++++---- crates/workspace/src/multi_workspace.rs | 23 +++ 6 files changed, 307 insertions(+), 56 deletions(-) diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 8466c93349b078..414f52f43f88e7 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -713,9 +713,12 @@ pub struct SiblingThreadRequest { /// 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. - /// Not yet supported; passing `true` will return an error. + /// 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, @@ -730,6 +733,11 @@ pub struct SiblingThreadInfo { 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. diff --git a/crates/agent/src/tools/create_thread_tool.rs b/crates/agent/src/tools/create_thread_tool.rs index 4c441d8e8c3997..9f87412d027973 100644 --- a/crates/agent/src/tools/create_thread_tool.rs +++ b/crates/agent/src/tools/create_thread_tool.rs @@ -37,8 +37,25 @@ use crate::{AgentTool, SiblingThreadRequest, ThreadEnvironment, ToolCallEventStr /// - Leave `agent` and `model` unset to use the user's current defaults. /// /// ### Worktree support -/// Not yet implemented. Setting `use_new_worktree` to true currently returns an -/// error. Sibling threads always share the parent thread's worktree for now. +/// 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 { @@ -62,13 +79,20 @@ pub struct CreateThreadToolInput { #[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. NOT YET SUPPORTED — passing true currently fails. + /// 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, - /// Git ref to base the new worktree on. Only used when `use_new_worktree` - /// is true. + /// 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, } @@ -81,6 +105,11 @@ pub enum CreateThreadToolOutput { 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, @@ -137,17 +166,12 @@ impl AgentTool for CreateThreadTool { 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}"), - })?; - - if input.use_new_worktree { - return Err(CreateThreadToolOutput::Error { - error: "Creating sibling threads in a new git worktree is not yet supported. \ - Set `use_new_worktree` to false to create the thread in the current worktree." - .into(), - }); - } + 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 { @@ -156,6 +180,7 @@ impl AgentTool for CreateThreadTool { agent_id: input.agent, model: input.model, use_new_worktree: input.use_new_worktree, + worktree_name: input.worktree_name, base_ref: input.base_ref, }; @@ -165,6 +190,7 @@ impl AgentTool for CreateThreadTool { 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_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 2485fbe34b45ab..ad2bff5348aa56 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -61,7 +61,7 @@ use crate::{ }; use agent_settings::AgentSettings; use ai_onboarding::AgentPanelOnboarding; -use anyhow::{Result, anyhow}; +use anyhow::{Context as _, Result, anyhow}; #[cfg(feature = "audio")] use audio::{Audio, Sound}; use chrono::{DateTime, Utc}; @@ -880,6 +880,10 @@ pub struct CreateThreadOptions { /// 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 { @@ -3013,7 +3017,7 @@ impl AgentPanel { agent, None, None, - None, + options.work_dirs, options.title.clone(), options.initial_content, options.model, @@ -4661,14 +4665,85 @@ impl agent::SiblingThreadHost for AgentPanelSiblingHost { 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 = window.update(cx, |_root, window, cx| { - panel.update(cx, |panel, cx| { + 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, @@ -4686,6 +4761,7 @@ impl agent::SiblingThreadHost for AgentPanelSiblingHost { title, agent_id: resolved_agent_id.0.to_string(), model: request.model, + warning: worktree_warning, }) }) } 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/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.