From 4e0022cbc0596678837ce10d66f67404a592e68c Mon Sep 17 00:00:00 2001 From: Katie Geer Date: Fri, 10 Apr 2026 14:17:28 -0700 Subject: [PATCH 1/7] agent_ui: Replace raw error messages with user-friendly copy in the agent panel (#53099) Replaces raw provider error strings in the agent panel with specific, user-friendly error callouts. Each error now has a clear title, actionable copy, and appropriate buttons instead of the generic "An Error Happened" fallback. Error variants added: Variant | Title | Body | Trigger | |---|---|---|---| | `RateLimitExceeded` | Rate Limit Reached | {Provider}'s rate limit was reached. Zed will retry automatically. You can also wait a moment and try again. | Provider rate limit exhausted after retries | | `ServerOverloaded` | Provider Unavailable | {Provider}'s servers are temporarily unavailable. Zed will retry automatically. If the problem persists, check the provider's status page. | Provider server overloaded or internal server error | | `PromptTooLarge` | Context Too Large | This conversation is too long for the model's context window. Start a new thread or remove some attached files to continue. | Conversation exceeds model's context window | | `NoApiKey` | API Key Missing | No API key is configured for {Provider}. Add your key via the Agent Panel settings to continue. | No API key configured for a direct provider | | `StreamError` | Connection Interrupted | The connection to {Provider}'s API was interrupted. Zed will retry automatically. If the problem persists, check your network connection. | Stream dropped or I/O error during generation | | `InvalidApiKey` | Invalid API Key | The API key for {Provider} is invalid or has expired. Update your key via the Agent Panel settings to continue. | API key present but invalid or expired | | `PermissionDenied` | Permission Denied | {Provider}'s API rejected the request due to insufficient permissions. Check that your API key has access to this model. | API key lacks access to the requested model | | `RequestFailed` | Request Failed | The request could not be completed after multiple attempts. Try again in a moment. | Upstream provider unreachable after retries | | `MaxOutputTokens` | Output Limit Reached | The model stopped because it reached its maximum output length. You can ask it to continue where it left off. | Model hit its maximum output token budget | | `NoModelSelected` | No Model Selected | Select a model from the model picker below to get started. | No model configured when a message is sent | | `ApiError` | API Error | {Provider}'s API returned an unexpected error. If the problem persists, try switching models or restarting Zed. ## Approach - Added typed errors (`NoModelConfiguredError`, `MaxOutputTokensError`) where previously raw strings were used, so they can be reliably downcast - Extended `From for ThreadError` to downcast `LanguageModelCompletionError` variants before falling through to the generic `Other` case - Each variant has a dedicated `render_*` function with appropriate buttons (retry icon, New Thread, or none) - Telemetry events updated with specific `kind` labels for each new variant Release Notes: - Improved error messages in the agent panel to show specific, actionable copy instead of raw provider error strings Self-Review Checklist: - [ x] I've reviewed my own diff for quality, security, and reliability - [n/a] Unsafe blocks (if any) have justifying comments - [ x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [n/a ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: Improved error messages in the agent panel to show specific, actionable copy instead of raw provider error strings --- crates/acp_thread/src/acp_thread.rs | 20 +- crates/agent/src/thread.rs | 25 +- crates/agent_ui/src/conversation_view.rs | 103 ++++++-- .../src/conversation_view/thread_view.rs | 225 ++++++++++++++++++ 4 files changed, 346 insertions(+), 27 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 7fb48c132f971fd3449d116b22bd4437c1ebf611..2f3973fbcc94e2d06bdc08a91d61c53809a951ed 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -36,6 +36,18 @@ use util::path_list::PathList; use util::{ResultExt, get_default_system_shell_preferring_bash, paths::PathStyle}; use uuid::Uuid; +/// Returned when the model stops because it exhausted its output token budget. +#[derive(Debug)] +pub struct MaxOutputTokensError; + +impl std::fmt::Display for MaxOutputTokensError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "output token limit reached") + } +} + +impl std::error::Error for MaxOutputTokensError {} + /// Key used in ACP ToolCall meta to store the tool's programmatic name. /// This is a workaround since ACP's ToolCall doesn't have a dedicated name field. pub const TOOL_NAME_META_KEY: &str = "tool_name"; @@ -2272,17 +2284,15 @@ impl AcpThread { .is_some_and(|max| u.output_tokens >= max) }); - let message = if exceeded_max_output_tokens { + if exceeded_max_output_tokens { log::error!( "Max output tokens reached. Usage: {:?}", this.token_usage ); - "Maximum output tokens reached" } else { log::error!("Max tokens reached. Usage: {:?}", this.token_usage); - "Maximum tokens reached" - }; - return Err(anyhow!(message)); + } + return Err(anyhow!(MaxOutputTokensError)); } let canceled = matches!(r.stop_reason, acp::StopReason::Cancelled); diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index e3a075ada62b6108c489779d5261c1c89afec8aa..bd9ef285169bf98ce196990156a269e830ccd738 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -64,6 +64,18 @@ const TOOL_CANCELED_MESSAGE: &str = "Tool canceled by user"; pub const MAX_TOOL_NAME_LENGTH: usize = 64; pub const MAX_SUBAGENT_DEPTH: u8 = 1; +/// Returned when a turn is attempted but no language model has been selected. +#[derive(Debug)] +pub struct NoModelConfiguredError; + +impl std::fmt::Display for NoModelConfiguredError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "no language model configured") + } +} + +impl std::error::Error for NoModelConfiguredError {} + /// Context passed to a subagent thread for lifecycle management #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SubagentContext { @@ -1772,7 +1784,9 @@ impl Thread { &mut self, cx: &mut Context, ) -> Result>> { - let model = self.model().context("No language model configured")?; + let model = self + .model() + .ok_or_else(|| anyhow!(NoModelConfiguredError))?; log::info!("Thread::send called with model: {}", model.name().0); self.advance_prompt_id(); @@ -1896,7 +1910,10 @@ impl Thread { // mid-turn changes (e.g. the user switches model, toggles tools, // or changes profile) take effect between tool-call rounds. let (model, request) = this.update(cx, |this, cx| { - let model = this.model.clone().context("No language model configured")?; + let model = this + .model + .clone() + .ok_or_else(|| anyhow!(NoModelConfiguredError))?; this.refresh_turn_tools(cx); let request = this.build_completion_request(intent, cx)?; anyhow::Ok((model, request)) @@ -2742,7 +2759,9 @@ impl Thread { completion_intent }; - let model = self.model().context("No language model configured")?; + let model = self + .model() + .ok_or_else(|| anyhow!(NoModelConfiguredError))?; let tools = if let Some(turn) = self.running_turn.as_ref() { turn.tools .iter() diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index d38e1344701fc8681b0feaf2fa7843611750532d..528e38333144524c4a4dffa63a7a8b107c829e41 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -1,12 +1,15 @@ use acp_thread::{ AcpThread, AcpThreadEvent, AgentSessionInfo, AgentThreadEntry, AssistantMessage, - AssistantMessageChunk, AuthRequired, LoadError, MentionUri, PermissionOptionChoice, - PermissionOptions, PermissionPattern, RetryStatus, SelectedPermissionOutcome, ThreadStatus, - ToolCall, ToolCallContent, ToolCallStatus, UserMessageId, + AssistantMessageChunk, AuthRequired, LoadError, MaxOutputTokensError, MentionUri, + PermissionOptionChoice, PermissionOptions, PermissionPattern, RetryStatus, + SelectedPermissionOutcome, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus, + UserMessageId, }; use acp_thread::{AgentConnection, Plan}; use action_log::{ActionLog, ActionLogTelemetry, DiffStats}; -use agent::{NativeAgentServer, NativeAgentSessionList, SharedThread, ThreadStore}; +use agent::{ + NativeAgentServer, NativeAgentSessionList, NoModelConfiguredError, SharedThread, ThreadStore, +}; use agent_client_protocol as acp; #[cfg(test)] use agent_servers::AgentServerDelegate; @@ -34,7 +37,7 @@ use gpui::{ list, point, pulsating_between, }; use language::Buffer; -use language_model::LanguageModelRegistry; +use language_model::{LanguageModelCompletionError, LanguageModelRegistry}; use markdown::{Markdown, MarkdownElement, MarkdownFont, MarkdownStyle}; use parking_lot::RwLock; use project::{AgentId, AgentServerStore, Project, ProjectEntryId}; @@ -113,6 +116,31 @@ pub(crate) enum ThreadError { PaymentRequired, Refusal, AuthenticationRequired(SharedString), + RateLimitExceeded { + provider: SharedString, + }, + ServerOverloaded { + provider: SharedString, + }, + PromptTooLarge, + NoApiKey { + provider: SharedString, + }, + StreamError { + provider: SharedString, + }, + InvalidApiKey { + provider: SharedString, + }, + PermissionDenied { + provider: SharedString, + }, + RequestFailed, + MaxOutputTokens, + NoModelSelected, + ApiError { + provider: SharedString, + }, Other { message: SharedString, acp_error_code: Option, @@ -121,12 +149,57 @@ pub(crate) enum ThreadError { impl From for ThreadError { fn from(error: anyhow::Error) -> Self { - if error.is::() { + if error.is::() { + Self::MaxOutputTokens + } else if error.is::() { + Self::NoModelSelected + } else if error.is::() { Self::PaymentRequired } else if let Some(acp_error) = error.downcast_ref::() && acp_error.code == acp::ErrorCode::AuthRequired { Self::AuthenticationRequired(acp_error.message.clone().into()) + } else if let Some(lm_error) = error.downcast_ref::() { + use LanguageModelCompletionError::*; + match lm_error { + RateLimitExceeded { provider, .. } => Self::RateLimitExceeded { + provider: provider.to_string().into(), + }, + ServerOverloaded { provider, .. } | ApiInternalServerError { provider, .. } => { + Self::ServerOverloaded { + provider: provider.to_string().into(), + } + } + PromptTooLarge { .. } => Self::PromptTooLarge, + NoApiKey { provider } => Self::NoApiKey { + provider: provider.to_string().into(), + }, + StreamEndedUnexpectedly { provider } + | ApiReadResponseError { provider, .. } + | DeserializeResponse { provider, .. } + | HttpSend { provider, .. } => Self::StreamError { + provider: provider.to_string().into(), + }, + AuthenticationError { provider, .. } => Self::InvalidApiKey { + provider: provider.to_string().into(), + }, + PermissionError { provider, .. } => Self::PermissionDenied { + provider: provider.to_string().into(), + }, + UpstreamProviderError { .. } => Self::RequestFailed, + BadRequestFormat { provider, .. } + | HttpResponseError { provider, .. } + | ApiEndpointNotFound { provider } => Self::ApiError { + provider: provider.to_string().into(), + }, + _ => { + let message: SharedString = format!("{:#}", error).into(); + Self::Other { + message, + acp_error_code: None, + } + } + } } else { let message: SharedString = format!("{:#}", error).into(); @@ -6625,19 +6698,11 @@ pub(crate) mod tests { conversation_view.read_with(cx, |conversation_view, cx| { let state = conversation_view.active_thread().unwrap(); let error = &state.read(cx).thread_error; - match error { - Some(ThreadError::Other { message, .. }) => { - assert!( - message.contains("Maximum tokens reached"), - "Expected 'Maximum tokens reached' error, got: {}", - message - ); - } - other => panic!( - "Expected ThreadError::Other with 'Maximum tokens reached', got: {:?}", - other.is_some() - ), - } + assert!( + matches!(error, Some(ThreadError::MaxOutputTokens)), + "Expected ThreadError::MaxOutputTokens, got: {:?}", + error.is_some() + ); }); } diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 32fe52480e2c347cc482b2296a107ee8731fb672..6fa5f5999c84c5190163be5904828cbbd3ebf053 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -1259,6 +1259,62 @@ impl ThreadView { ThreadError::AuthenticationRequired(message) => { ("authentication_required", None, message.clone()) } + ThreadError::RateLimitExceeded { provider } => ( + "rate_limit_exceeded", + None, + format!("{provider}'s rate limit was reached.").into(), + ), + ThreadError::ServerOverloaded { provider } => ( + "server_overloaded", + None, + format!("{provider}'s servers are temporarily unavailable.").into(), + ), + ThreadError::PromptTooLarge => ( + "prompt_too_large", + None, + "Context too large for the model's context window.".into(), + ), + ThreadError::NoApiKey { provider } => ( + "no_api_key", + None, + format!("No API key configured for {provider}.").into(), + ), + ThreadError::StreamError { provider } => ( + "stream_error", + None, + format!("Connection to {provider}'s API was interrupted.").into(), + ), + ThreadError::InvalidApiKey { provider } => ( + "invalid_api_key", + None, + format!("Invalid or expired API key for {provider}.").into(), + ), + ThreadError::PermissionDenied { provider } => ( + "permission_denied", + None, + format!( + "{provider}'s API rejected the request due to insufficient permissions." + ) + .into(), + ), + ThreadError::RequestFailed => ( + "request_failed", + None, + "Request could not be completed after multiple attempts.".into(), + ), + ThreadError::MaxOutputTokens => ( + "max_output_tokens", + None, + "Model reached its maximum output length.".into(), + ), + ThreadError::NoModelSelected => { + ("no_model_selected", None, "No model selected.".into()) + } + ThreadError::ApiError { provider } => ( + "api_error", + None, + format!("{provider}'s API returned an unexpected error.").into(), + ), ThreadError::Other { acp_error_code, message, @@ -8088,6 +8144,109 @@ impl ThreadView { self.render_authentication_required_error(error.clone(), cx) } ThreadError::PaymentRequired => self.render_payment_required_error(cx), + ThreadError::RateLimitExceeded { provider } => self.render_error_callout( + "Rate Limit Reached", + format!( + "{provider}'s rate limit was reached. Zed will retry automatically. \ + You can also wait a moment and try again." + ) + .into(), + true, + true, + cx, + ), + ThreadError::ServerOverloaded { provider } => self.render_error_callout( + "Provider Unavailable", + format!( + "{provider}'s servers are temporarily unavailable. Zed will retry \ + automatically. If the problem persists, check the provider's status page." + ) + .into(), + true, + true, + cx, + ), + ThreadError::PromptTooLarge => self.render_prompt_too_large_error(cx), + ThreadError::NoApiKey { provider } => self.render_error_callout( + "API Key Missing", + format!( + "No API key is configured for {provider}. \ + Add your key via the Agent Panel settings to continue." + ) + .into(), + false, + true, + cx, + ), + ThreadError::StreamError { provider } => self.render_error_callout( + "Connection Interrupted", + format!( + "The connection to {provider}'s API was interrupted. Zed will retry \ + automatically. If the problem persists, check your network connection." + ) + .into(), + true, + true, + cx, + ), + ThreadError::InvalidApiKey { provider } => self.render_error_callout( + "Invalid API Key", + format!( + "The API key for {provider} is invalid or has expired. \ + Update your key via the Agent Panel settings to continue." + ) + .into(), + false, + false, + cx, + ), + ThreadError::PermissionDenied { provider } => self.render_error_callout( + "Permission Denied", + format!( + "{provider}'s API rejected the request due to insufficient permissions. \ + Check that your API key has access to this model." + ) + .into(), + false, + false, + cx, + ), + ThreadError::RequestFailed => self.render_error_callout( + "Request Failed", + "The request could not be completed after multiple attempts. \ + Try again in a moment." + .into(), + true, + false, + cx, + ), + ThreadError::MaxOutputTokens => self.render_error_callout( + "Output Limit Reached", + "The model stopped because it reached its maximum output length. \ + You can ask it to continue where it left off." + .into(), + false, + false, + cx, + ), + ThreadError::NoModelSelected => self.render_error_callout( + "No Model Selected", + "Select a model from the model picker below to get started.".into(), + false, + false, + cx, + ), + ThreadError::ApiError { provider } => self.render_error_callout( + "API Error", + format!( + "{provider}'s API returned an unexpected error. \ + If the problem persists, try switching models or restarting Zed." + ) + .into(), + true, + true, + cx, + ), }; Some(div().child(content)) @@ -8148,6 +8307,72 @@ impl ThreadView { .dismiss_action(self.dismiss_error_button(cx)) } + fn render_error_callout( + &self, + title: &'static str, + message: SharedString, + show_retry: bool, + show_copy: bool, + cx: &mut Context, + ) -> Callout { + let can_resume = show_retry && self.thread.read(cx).can_retry(cx); + let show_actions = can_resume || show_copy; + + Callout::new() + .severity(Severity::Error) + .icon(IconName::XCircle) + .title(title) + .description(message.clone()) + .when(show_actions, |callout| { + callout.actions_slot( + h_flex() + .gap_0p5() + .when(can_resume, |this| this.child(self.retry_button(cx))) + .when(show_copy, |this| { + this.child(self.create_copy_button(message.clone())) + }), + ) + }) + .dismiss_action(self.dismiss_error_button(cx)) + } + + fn render_prompt_too_large_error(&self, cx: &mut Context) -> Callout { + const MESSAGE: &str = "This conversation is too long for the model's context window. \ + Start a new thread or remove some attached files to continue."; + + Callout::new() + .severity(Severity::Error) + .icon(IconName::XCircle) + .title("Context Too Large") + .description(MESSAGE) + .actions_slot( + h_flex() + .gap_0p5() + .child(self.new_thread_button(cx)) + .child(self.create_copy_button(MESSAGE)), + ) + .dismiss_action(self.dismiss_error_button(cx)) + } + + fn retry_button(&self, cx: &mut Context) -> impl IntoElement { + Button::new("retry", "Retry") + .label_size(LabelSize::Small) + .style(ButtonStyle::Filled) + .on_click(cx.listener(|this, _, _, cx| { + this.retry_generation(cx); + })) + } + + fn new_thread_button(&self, cx: &mut Context) -> impl IntoElement { + Button::new("new_thread", "New Thread") + .label_size(LabelSize::Small) + .style(ButtonStyle::Filled) + .on_click(cx.listener(|this, _, window, cx| { + this.clear_thread_error(cx); + window.dispatch_action(NewThread.boxed_clone(), cx); + })) + } + fn upgrade_button(&self, cx: &mut Context) -> impl IntoElement { Button::new("upgrade", "Upgrade") .label_size(LabelSize::Small) From cca6552bc4228b0ab33a57ede4a5f4a46571035b Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 10 Apr 2026 14:29:03 -0700 Subject: [PATCH 2/7] Open new remote projects in the same window via sidebar (#53654) Previously, when opening a remote project via the sidebar, and picking a new project path via the path picker, it would incorrectly open in a new window. Release Notes: - N/A --- crates/recent_projects/src/remote_servers.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index d360ba4233d036191617c89ffb92b9decced5002..0e15abf296e491185f24718cddf72e2532e9e6aa 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -488,18 +488,21 @@ impl ProjectPicker { }) .log_err(); - let options = cx - .update(|_, cx| (app_state.build_window_options)(None, cx)) - .log_err()?; - let window = cx - .open_window(options, |window, cx| { + let window = if create_new_window { + let options = cx + .update(|_, cx| (app_state.build_window_options)(None, cx)) + .log_err()?; + cx.open_window(options, |window, cx| { let workspace = cx.new(|cx| { telemetry::event!("SSH Project Created"); Workspace::new(None, project.clone(), app_state.clone(), window, cx) }); cx.new(|cx| MultiWorkspace::new(workspace, window, cx)) }) - .log_err()?; + .log_err() + } else { + cx.window_handle().downcast::() + }?; let items = open_remote_project_with_existing_connection( connection, project, paths, app_state, window, None, cx, From ed2f21aa056f854b2fa1021f6062eb3204db36b9 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 10 Apr 2026 19:19:00 -0300 Subject: [PATCH 3/7] agent_ui: Fix scroll area in zoomed-in panel (#53657) When the agent panel is zoomed-in, or if it's wide enough to the point you see a lot of horizontal padding due to the max-width, it wasn't previously possible to scroll the thread in that padding while your mouse was resting in that area. Release Notes: - Agent: Fixed scroll behavior when the agent panel is zoomed-in. --- .../src/conversation_view/thread_view.rs | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 6fa5f5999c84c5190163be5904828cbbd3ebf053..c856c7c69a48c1e5a27e9ec48cd2adb3fa0ee702 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -4387,17 +4387,27 @@ impl Render for TokenUsageTooltip { impl ThreadView { fn render_entries(&mut self, cx: &mut Context) -> List { + let max_content_width = AgentSettings::get_global(cx).max_content_width; + let centered_container = move |content: AnyElement| { + h_flex() + .w_full() + .justify_center() + .child(div().max_w(max_content_width).w_full().child(content)) + }; + list( self.list_state.clone(), cx.processor(move |this, index: usize, window, cx| { let entries = this.thread.read(cx).entries(); if let Some(entry) = entries.get(index) { - this.render_entry(index, entries.len(), entry, window, cx) + let rendered = this.render_entry(index, entries.len(), entry, window, cx); + centered_container(rendered.into_any_element()).into_any_element() } else if this.generating_indicator_in_list { let confirmation = entries .last() .is_some_and(|entry| Self::is_waiting_for_confirmation(entry)); - this.render_generating(confirmation, cx).into_any_element() + let rendered = this.render_generating(confirmation, cx); + centered_container(rendered.into_any_element()).into_any_element() } else { Empty.into_any() } @@ -8783,7 +8793,6 @@ impl ThreadView { impl Render for ThreadView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let has_messages = self.list_state.item_count() > 0; - let max_content_width = AgentSettings::get_global(cx).max_content_width; let list_state = self.list_state.clone(); let conversation = v_flex() @@ -8794,13 +8803,7 @@ impl Render for ThreadView { if has_messages { this.flex_1() .size_full() - .child( - v_flex() - .mx_auto() - .max_w(max_content_width) - .size_full() - .child(self.render_entries(cx)), - ) + .child(self.render_entries(cx)) .vertical_scrollbar_for(&list_state, window, cx) .into_any() } else { From b5e1aea0e155a0a9399e4d92ae6ef2c6860332bd Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:04:53 -0300 Subject: [PATCH 4/7] Add callout communicating about multi-root setups in ACP agents (#53660) This PR adds a callout in the agent panel communicating about the lack of support for multi-root workspaces within ACP agents. Release Notes: - N/A --- .../src/conversation_view/thread_view.rs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index c856c7c69a48c1e5a27e9ec48cd2adb3fa0ee702..66a211b2f4cf048301da1e7c154d50db191ec15a 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -330,6 +330,7 @@ pub struct ThreadView { pub hovered_recent_history_item: Option, pub show_external_source_prompt_warning: bool, pub show_codex_windows_warning: bool, + pub multi_root_callout_dismissed: bool, pub generating_indicator_in_list: bool, pub history: Option>, pub _history_subscription: Option, @@ -573,6 +574,7 @@ impl ThreadView { history, _history_subscription: history_subscription, show_codex_windows_warning, + multi_root_callout_dismissed: false, generating_indicator_in_list: false, }; @@ -8585,6 +8587,53 @@ impl ThreadView { ) } + fn render_multi_root_callout(&self, cx: &mut Context) -> Option { + if self.multi_root_callout_dismissed { + return None; + } + + if self.as_native_connection(cx).is_some() { + return None; + } + + let project = self.project.upgrade()?; + let worktree_count = project.read(cx).visible_worktrees(cx).count(); + if worktree_count <= 1 { + return None; + } + + let work_dirs = self.thread.read(cx).work_dirs()?; + let active_dir = work_dirs + .ordered_paths() + .next() + .and_then(|p| p.file_name()) + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "one folder".to_string()); + + let description = format!( + "This agent only operates on \"{}\". Other folders in this workspace are not accessible to it.", + active_dir + ); + + Some( + Callout::new() + .severity(Severity::Warning) + .icon(IconName::Warning) + .title("External Agents currently don't support multi-root workspaces") + .description(description) + .border_position(ui::BorderPosition::Bottom) + .dismiss_action( + IconButton::new("dismiss-multi-root-callout", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Dismiss")) + .on_click(cx.listener(|this, _, _, cx| { + this.multi_root_callout_dismissed = true; + cx.notify(); + })), + ), + ) + } + fn render_new_version_callout(&self, version: &SharedString, cx: &mut Context) -> Div { let server_view = self.server_view.clone(); let has_version = !version.is_empty(); @@ -8988,6 +9037,7 @@ impl Render for ThreadView { .size_full() .children(self.render_subagent_titlebar(cx)) .child(conversation) + .children(self.render_multi_root_callout(cx)) .children(self.render_activity_bar(window, cx)) .when(self.show_external_source_prompt_warning, |this| { this.child(self.render_external_source_prompt_warning(cx)) From f4477249f281f0e1ba4e05048c750f3f85980290 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 10 Apr 2026 16:06:15 -0700 Subject: [PATCH 5/7] Do not include non-existent folders in recent projects, while retaining them for a one week grace period (#53662) Fixes a regression introduced in https://github.com/zed-industries/zed/issues/49603 Supersedes https://github.com/zed-industries/zed/pull/52638 We want to leave these non-existent workspaces in the database for up to 7 days, in case they are on external drives that are restored later, but we do *not* want to show them in the UI if they don't exist. Release Notes: - Fixed an issue where deleted folders appeared in the recent project picker --------- Co-authored-by: ojpro --- crates/workspace/src/persistence.rs | 54 +++++++++++++++-------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 67383740a8b3287bb237748776b0c7ab2654d7ba..9ae44ef3db2e6c18979694440744043a6abc055e 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -1804,16 +1804,12 @@ impl WorkspaceDb { } } - async fn all_paths_exist_with_a_directory( - paths: &[PathBuf], - fs: &dyn Fs, - timestamp: Option>, - ) -> bool { + async fn all_paths_exist_with_a_directory(paths: &[PathBuf], fs: &dyn Fs) -> bool { let mut any_dir = false; for path in paths { match fs.metadata(path).await.ok().flatten() { None => { - return timestamp.is_some_and(|t| Utc::now() - t < chrono::Duration::days(7)); + return false; } Some(meta) => { if meta.is_dir { @@ -1839,9 +1835,9 @@ impl WorkspaceDb { )>, > { let mut result = Vec::new(); - let mut delete_tasks = Vec::new(); + let mut workspaces_to_delete = Vec::new(); let remote_connections = self.remote_connections()?; - + let now = Utc::now(); for (id, paths, remote_connection_id, timestamp) in self.recent_workspaces()? { if let Some(remote_connection_id) = remote_connection_id { if let Some(connection_options) = remote_connections.get(&remote_connection_id) { @@ -1852,34 +1848,40 @@ impl WorkspaceDb { timestamp, )); } else { - delete_tasks.push(self.delete_workspace_by_id(id)); + workspaces_to_delete.push(id); } continue; } - let has_wsl_path = if cfg!(windows) { - paths + // Delete the workspace if any of the paths are WSL paths. If a + // local workspace points to WSL, attempting to read its metadata + // will wait for the WSL VM and file server to boot up. This can + // block for many seconds. Supported scenarios use remote + // workspaces. + if cfg!(windows) { + let has_wsl_path = paths .paths() .iter() - .any(|path| util::paths::WslPath::from_path(path).is_some()) - } else { - false - }; + .any(|path| util::paths::WslPath::from_path(path).is_some()); + if has_wsl_path { + workspaces_to_delete.push(id); + continue; + } + } - // Delete the workspace if any of the paths are WSL paths. - // If a local workspace points to WSL, this check will cause us to wait for the - // WSL VM and file server to boot up. This can block for many seconds. - // Supported scenarios use remote workspaces. - if !has_wsl_path - && Self::all_paths_exist_with_a_directory(paths.paths(), fs, Some(timestamp)).await - { + if Self::all_paths_exist_with_a_directory(paths.paths(), fs).await { result.push((id, SerializedWorkspaceLocation::Local, paths, timestamp)); - } else { - delete_tasks.push(self.delete_workspace_by_id(id)); + } else if now - timestamp >= chrono::Duration::days(7) { + workspaces_to_delete.push(id); } } - futures::future::join_all(delete_tasks).await; + futures::future::join_all( + workspaces_to_delete + .into_iter() + .map(|id| self.delete_workspace_by_id(id)), + ) + .await; Ok(result) } @@ -1932,7 +1934,7 @@ impl WorkspaceDb { window_id, }); } else { - if Self::all_paths_exist_with_a_directory(paths.paths(), fs, None).await { + if Self::all_paths_exist_with_a_directory(paths.paths(), fs).await { workspaces.push(SessionWorkspace { workspace_id, location: SerializedWorkspaceLocation::Local, From 86f55495c21260adf0da5b7afebebd2d5ca0fa4a Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:19:55 -0300 Subject: [PATCH 6/7] sidebar: Add a new thread draft system (#53574) Release Notes: - N/A --------- Co-authored-by: Mikayla Maki Co-authored-by: Nathan Sobo --- crates/agent_ui/src/agent_panel.rs | 178 +++- crates/agent_ui/src/agent_ui.rs | 4 +- crates/agent_ui/src/thread_import.rs | 4 +- .../agent_ui/src/thread_worktree_archive.rs | 21 +- crates/agent_ui/src/threads_archive_view.rs | 4 +- crates/sidebar/src/sidebar.rs | 894 ++++++++++-------- crates/sidebar/src/sidebar_tests.rs | 673 ++++++++++--- 7 files changed, 1227 insertions(+), 551 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 2ff4cd18a78fd53c5d540e66670d6e6c9e51aa47..8005445034d0b9339d36cb2d48da516f9c2a9207 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -56,7 +56,7 @@ use extension_host::ExtensionStore; use fs::Fs; use gpui::{ Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner, - DismissEvent, Entity, EntityId, EventEmitter, ExternalPaths, FocusHandle, Focusable, + DismissEvent, Entity, EntityId, EventEmitter, ExternalPaths, FocusHandle, Focusable, Global, KeyContext, Pixels, Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between, }; @@ -204,21 +204,12 @@ pub fn init(cx: &mut App) { panel.update(cx, |panel, cx| panel.open_configuration(window, cx)); } }) - .register_action(|workspace, action: &NewExternalAgentThread, window, cx| { + .register_action(|workspace, _action: &NewExternalAgentThread, window, cx| { if let Some(panel) = workspace.panel::(cx) { workspace.focus_panel::(window, cx); panel.update(cx, |panel, cx| { - let initial_content = panel.take_active_draft_initial_content(cx); - panel.external_thread( - action.agent.clone(), - None, - None, - None, - initial_content, - true, - window, - cx, - ) + let id = panel.create_draft(window, cx); + panel.activate_draft(id, true, window, cx); }); } }) @@ -602,6 +593,25 @@ fn build_conflicted_files_resolution_prompt( content } +/// Unique identifier for a sidebar draft thread. Not persisted across restarts. +/// IDs are globally unique across all AgentPanel instances within the same app. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct DraftId(pub usize); + +#[derive(Default)] +struct DraftIdCounter(usize); + +impl Global for DraftIdCounter {} + +impl DraftId { + fn next(cx: &mut App) -> Self { + let counter = cx.default_global::(); + let id = counter.0; + counter.0 += 1; + Self(id) + } +} + enum ActiveView { Uninitialized, AgentThread { @@ -803,6 +813,7 @@ pub struct AgentPanel { active_view: ActiveView, previous_view: Option, background_threads: HashMap>, + draft_threads: HashMap>, new_thread_menu_handle: PopoverMenuHandle, start_thread_in_menu_handle: PopoverMenuHandle, thread_branch_menu_handle: PopoverMenuHandle, @@ -1181,6 +1192,7 @@ impl AgentPanel { context_server_registry, previous_view: None, background_threads: HashMap::default(), + draft_threads: HashMap::default(), new_thread_menu_handle: PopoverMenuHandle::default(), start_thread_in_menu_handle: PopoverMenuHandle::default(), thread_branch_menu_handle: PopoverMenuHandle::default(), @@ -1306,9 +1318,96 @@ impl AgentPanel { } pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context) { - self.reset_start_thread_in_to_default(cx); - let initial_content = self.take_active_draft_initial_content(cx); - self.external_thread(None, None, None, None, initial_content, true, window, cx); + let id = self.create_draft(window, cx); + self.activate_draft(id, true, window, cx); + } + + /// Creates a new empty draft thread and stores it. Returns the DraftId. + /// The draft is NOT activated — call `activate_draft` to show it. + pub fn create_draft(&mut self, window: &mut Window, cx: &mut Context) -> DraftId { + let id = DraftId::next(cx); + let workspace = self.workspace.clone(); + let project = self.project.clone(); + let fs = self.fs.clone(); + let thread_store = self.thread_store.clone(); + let agent = if self.project.read(cx).is_via_collab() { + Agent::NativeAgent + } else { + self.selected_agent.clone() + }; + let server = agent.server(fs, thread_store); + let conversation_view = self.create_agent_thread( + server, None, None, None, None, workspace, project, agent, window, cx, + ); + self.draft_threads.insert(id, conversation_view); + id + } + + pub fn activate_draft( + &mut self, + id: DraftId, + focus: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(conversation_view) = self.draft_threads.get(&id).cloned() else { + return; + }; + self.set_active_view( + ActiveView::AgentThread { conversation_view }, + focus, + window, + cx, + ); + } + + /// Removes a draft thread. If it's currently active, does nothing to + /// the active view — the caller should activate something else first. + pub fn remove_draft(&mut self, id: DraftId) { + self.draft_threads.remove(&id); + } + + /// Returns the DraftId of the currently active draft, if the active + /// view is a draft thread tracked in `draft_threads`. + pub fn active_draft_id(&self) -> Option { + let active_cv = self.active_conversation_view()?; + self.draft_threads + .iter() + .find_map(|(id, cv)| (cv.entity_id() == active_cv.entity_id()).then_some(*id)) + } + + /// Returns all draft IDs, sorted newest-first. + pub fn draft_ids(&self) -> Vec { + let mut ids: Vec = self.draft_threads.keys().copied().collect(); + ids.sort_by_key(|id| std::cmp::Reverse(id.0)); + ids + } + + /// Returns the text from a draft's message editor, or `None` if the + /// draft doesn't exist or has no text. + pub fn draft_editor_text(&self, id: DraftId, cx: &App) -> Option { + let cv = self.draft_threads.get(&id)?; + let tv = cv.read(cx).active_thread()?; + let text = tv.read(cx).message_editor.read(cx).text(cx); + if text.trim().is_empty() { + None + } else { + Some(text) + } + } + + /// Clears the message editor text of a tracked draft. + pub fn clear_draft_editor(&self, id: DraftId, window: &mut Window, cx: &mut Context) { + let Some(cv) = self.draft_threads.get(&id) else { + return; + }; + let Some(tv) = cv.read(cx).active_thread() else { + return; + }; + let editor = tv.read(cx).message_editor.clone(); + editor.update(cx, |editor, cx| { + editor.clear(window, cx); + }); } fn take_active_draft_initial_content( @@ -1410,7 +1509,7 @@ impl AgentPanel { }); let server = agent.server(fs, thread_store); - self.create_agent_thread( + let conversation_view = self.create_agent_thread( server, resume_session_id, work_dirs, @@ -1419,6 +1518,11 @@ impl AgentPanel { workspace, project, agent, + window, + cx, + ); + self.set_active_view( + ActiveView::AgentThread { conversation_view }, focus, window, cx, @@ -1982,6 +2086,16 @@ impl AgentPanel { return; }; + // If this ConversationView is a tracked draft, it's already + // stored in `draft_threads` — don't drop it. + let is_tracked_draft = self + .draft_threads + .values() + .any(|cv| cv.entity_id() == conversation_view.entity_id()); + if is_tracked_draft { + return; + } + let Some(thread_view) = conversation_view.read(cx).root_thread(cx) else { return; }; @@ -2188,6 +2302,12 @@ impl AgentPanel { this.handle_first_send_requested(view.clone(), content.clone(), window, cx); } AcpThreadViewEvent::MessageSentOrQueued => { + // When a draft sends its first message it becomes a + // real thread. Remove it from `draft_threads` so the + // sidebar stops showing a stale draft entry. + if let Some(draft_id) = this.active_draft_id() { + this.draft_threads.remove(&draft_id); + } let session_id = view.read(cx).thread.read(cx).session_id().clone(); cx.emit(AgentPanelEvent::MessageSentOrQueued { session_id }); } @@ -2528,10 +2648,9 @@ impl AgentPanel { workspace: WeakEntity, project: Entity, agent: Agent, - focus: bool, window: &mut Window, cx: &mut Context, - ) { + ) -> Entity { if self.selected_agent != agent { self.selected_agent = agent.clone(); self.serialize(cx); @@ -2586,12 +2705,7 @@ impl AgentPanel { }) .detach(); - self.set_active_view( - ActiveView::AgentThread { conversation_view }, - focus, - window, - cx, - ); + conversation_view } fn active_thread_has_messages(&self, cx: &App) -> bool { @@ -3457,8 +3571,8 @@ impl Panel for AgentPanel { Some((_, WorktreeCreationStatus::Creating)) ) { - let selected_agent = self.selected_agent.clone(); - self.new_agent_thread_inner(selected_agent, false, window, cx); + let id = self.create_draft(window, cx); + self.activate_draft(id, false, window, cx); } } @@ -4745,8 +4859,14 @@ impl AgentPanel { id: server.agent_id(), }; - self.create_agent_thread( - server, None, None, None, None, workspace, project, ext_agent, true, window, cx, + let conversation_view = self.create_agent_thread( + server, None, None, None, None, workspace, project, ext_agent, window, cx, + ); + self.set_active_view( + ActiveView::AgentThread { conversation_view }, + true, + window, + cx, ); } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 2cf4218719a0412534d9832c3cb54587f4c45a73..2e2e4018e3cc9521c7b2e106a87281a4f37b0796 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -65,11 +65,11 @@ use std::any::TypeId; use workspace::Workspace; use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal}; -pub use crate::agent_panel::{AgentPanel, AgentPanelEvent, WorktreeCreationStatus}; +pub use crate::agent_panel::{AgentPanel, AgentPanelEvent, DraftId, WorktreeCreationStatus}; use crate::agent_registry_ui::AgentRegistryPage; pub use crate::inline_assistant::InlineAssistant; pub use agent_diff::{AgentDiffPane, AgentDiffToolbar}; -pub(crate) use conversation_view::ConversationView; +pub use conversation_view::ConversationView; pub use external_source_prompt::ExternalSourcePrompt; pub(crate) use mode_selector::ModeSelector; pub(crate) use model_selector::ModelSelector; diff --git a/crates/agent_ui/src/thread_import.rs b/crates/agent_ui/src/thread_import.rs index 686ca5d6cd4fdfede7eb4a5ed70c90074972fdf4..78de6fd34e2cab57377c640d27bd21c26f8a7339 100644 --- a/crates/agent_ui/src/thread_import.rs +++ b/crates/agent_ui/src/thread_import.rs @@ -342,9 +342,9 @@ impl Render for ThreadImportModal { Modal::new("import-threads", None) .header( ModalHeader::new() - .headline("Import ACP Threads") + .headline("Import External Agent Threads") .description( - "Import threads from your ACP agents — whether started in Zed or another client. \ + "Import threads from agents like Claude Agent, Codex, and more, whether started in Zed or another client. \ Choose which agents to include, and their threads will appear in your archive." ) .show_dismiss_button(true), diff --git a/crates/agent_ui/src/thread_worktree_archive.rs b/crates/agent_ui/src/thread_worktree_archive.rs index 4398a2154d4abd550535b247ab1a9e518f84b39d..723e6779a44eecb9b4ef05049f392785f84041b1 100644 --- a/crates/agent_ui/src/thread_worktree_archive.rs +++ b/crates/agent_ui/src/thread_worktree_archive.rs @@ -139,16 +139,6 @@ pub fn build_root_plan( .then_some((snapshot, repo)) }); - let matching_worktree_snapshot = workspaces.iter().find_map(|workspace| { - workspace - .read(cx) - .project() - .read(cx) - .visible_worktrees(cx) - .find(|worktree| worktree.read(cx).abs_path().as_ref() == path.as_path()) - .map(|worktree| worktree.read(cx).snapshot()) - }); - let (main_repo_path, worktree_repo, branch_name) = if let Some((linked_snapshot, repo)) = linked_repo { ( @@ -160,12 +150,11 @@ pub fn build_root_plan( .map(|branch| branch.name().to_string()), ) } else { - let main_repo_path = matching_worktree_snapshot - .as_ref()? - .root_repo_common_dir() - .and_then(|dir| dir.parent())? - .to_path_buf(); - (main_repo_path, None, None) + // Not a linked worktree — nothing to archive from disk. + // `remove_root` would try to remove the main worktree from + // the project and then run `git worktree remove`, both of + // which fail for main working trees. + return None; }; Some(RootPlan { diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index 6e73584ef87f11810e4c860cc6ff4c8d8ff015a9..817a9deb3ae9223c0fe2bd169563bb9c5c6cb5a9 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -553,7 +553,6 @@ impl ThreadsArchiveView { base.status(AgentThreadStatus::Running) .action_slot( IconButton::new("cancel-restore", IconName::Close) - .style(ButtonStyle::Filled) .icon_size(IconSize::Small) .icon_color(Color::Muted) .tooltip(Tooltip::text("Cancel Restore")) @@ -568,12 +567,11 @@ impl ThreadsArchiveView { }) }), ) - .tooltip(Tooltip::text("Restoring\u{2026}")) + .tooltip(Tooltip::text("Restoring…")) .into_any_element() } else { base.action_slot( IconButton::new("delete-thread", IconName::Trash) - .style(ButtonStyle::Filled) .icon_size(IconSize::Small) .icon_color(Color::Muted) .tooltip({ diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 4d88ddeffdd6625768dd0207176c0984e9833a29..f69e5206a6e236bd602e5d1922d33b36afb6b520 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -9,9 +9,9 @@ use agent_ui::thread_worktree_archive; use agent_ui::threads_archive_view::{ ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp, }; -use agent_ui::{AcpThreadImportOnboarding, ThreadImportModal}; use agent_ui::{ - Agent, AgentPanel, AgentPanelEvent, DEFAULT_THREAD_TITLE, NewThread, RemoveSelectedThread, + AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, DEFAULT_THREAD_TITLE, DraftId, + NewThread, RemoveSelectedThread, ThreadImportModal, }; use chrono::{DateTime, Utc}; use editor::Editor; @@ -38,9 +38,9 @@ use std::path::PathBuf; use std::rc::Rc; use theme::ActiveTheme; use ui::{ - AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, HighlightedLabel, KeyBinding, - PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, ThreadItemWorktreeInfo, TintColor, Tooltip, - WithScrollbar, prelude::*, + AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, GradientFade, HighlightedLabel, + KeyBinding, PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, ThreadItemWorktreeInfo, TintColor, + Tooltip, WithScrollbar, prelude::*, }; use util::ResultExt as _; use util::path_list::PathList; @@ -121,14 +121,17 @@ enum ActiveEntry { session_id: acp::SessionId, workspace: Entity, }, - Draft(Entity), + Draft { + id: DraftId, + workspace: Entity, + }, } impl ActiveEntry { fn workspace(&self) -> &Entity { match self { ActiveEntry::Thread { workspace, .. } => workspace, - ActiveEntry::Draft(workspace) => workspace, + ActiveEntry::Draft { workspace, .. } => workspace, } } @@ -136,17 +139,22 @@ impl ActiveEntry { matches!(self, ActiveEntry::Thread { session_id: id, .. } if id == session_id) } + fn is_active_draft(&self, draft_id: DraftId) -> bool { + matches!(self, ActiveEntry::Draft { id, .. } if *id == draft_id) + } + fn matches_entry(&self, entry: &ListEntry) -> bool { match (self, entry) { (ActiveEntry::Thread { session_id, .. }, ListEntry::Thread(thread)) => { thread.metadata.session_id == *session_id } ( - ActiveEntry::Draft(_), + ActiveEntry::Draft { id, .. }, ListEntry::DraftThread { - workspace: None, .. + draft_id: Some(entry_id), + .. }, - ) => true, + ) => *id == *entry_id, _ => false, } } @@ -245,9 +253,10 @@ enum ListEntry { key: ProjectGroupKey, is_fully_expanded: bool, }, - /// The user's active draft thread. Shows a prefix of the currently-typed - /// prompt, or "Untitled Thread" if the prompt is empty. DraftThread { + /// `None` for placeholder entries in empty groups with no open + /// workspace. `Some` for drafts backed by an AgentPanel. + draft_id: Option, key: project::ProjectGroupKey, workspace: Option>, worktrees: Vec, @@ -273,15 +282,7 @@ impl ListEntry { ThreadEntryWorkspace::Open(ws) => vec![ws.clone()], ThreadEntryWorkspace::Closed { .. } => Vec::new(), }, - ListEntry::DraftThread { workspace, .. } => { - if let Some(ws) = workspace { - vec![ws.clone()] - } else { - // workspace: None means this is the active draft, - // which always lives on the current workspace. - vec![multi_workspace.workspace().clone()] - } - } + ListEntry::DraftThread { workspace, .. } => workspace.iter().cloned().collect(), ListEntry::ProjectHeader { key, .. } => multi_workspace .workspaces_for_project_group(key, cx) .cloned() @@ -595,10 +596,6 @@ impl Sidebar { cx.emit(workspace::SidebarEvent::SerializeNeeded); } - fn active_entry_workspace(&self) -> Option<&Entity> { - self.active_entry.as_ref().map(|entry| entry.workspace()) - } - fn is_active_workspace(&self, workspace: &Entity, cx: &App) -> bool { self.multi_workspace .upgrade() @@ -648,10 +645,10 @@ impl Sidebar { cx.subscribe_in( workspace, window, - |this, _workspace, event: &workspace::Event, window, cx| { + |this, _workspace, event: &workspace::Event, _window, cx| { if let workspace::Event::PanelAdded(view) = event { if let Ok(agent_panel) = view.clone().downcast::() { - this.subscribe_to_agent_panel(&agent_panel, window, cx); + this.subscribe_to_agent_panel(&agent_panel, _window, cx); } } }, @@ -675,21 +672,8 @@ impl Sidebar { cx.subscribe_in( agent_panel, window, - |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event { + |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event { AgentPanelEvent::ActiveViewChanged => { - let is_new_draft = agent_panel - .read(cx) - .active_conversation_view() - .is_some_and(|cv| cv.read(cx).parent_id(cx).is_none()); - if is_new_draft { - if let Some(active_workspace) = this - .multi_workspace - .upgrade() - .map(|mw| mw.read(cx).workspace().clone()) - { - this.active_entry = Some(ActiveEntry::Draft(active_workspace)); - } - } this.observe_draft_editor(cx); this.update_entries(cx); } @@ -749,26 +733,6 @@ impl Sidebar { }); } - fn active_draft_text(&self, cx: &App) -> Option { - let mw = self.multi_workspace.upgrade()?; - let workspace = mw.read(cx).workspace(); - let panel = workspace.read(cx).panel::(cx)?; - let conversation_view = panel.read(cx).active_conversation_view()?; - let thread_view = conversation_view.read(cx).active_thread()?; - let raw = thread_view.read(cx).message_editor.read(cx).text(cx); - let cleaned = Self::clean_mention_links(&raw); - let mut text: String = cleaned.split_whitespace().collect::>().join(" "); - if text.is_empty() { - None - } else { - const MAX_CHARS: usize = 250; - if let Some((truncate_at, _)) = text.char_indices().nth(MAX_CHARS) { - text.truncate(truncate_at); - } - Some(text.into()) - } - } - fn clean_mention_links(input: &str) -> String { let mut result = String::with_capacity(input.len()); let mut remaining = input; @@ -829,6 +793,42 @@ impl Sidebar { .detach_and_log_err(cx); } + fn open_workspace_and_create_draft( + &mut self, + project_group_key: &ProjectGroupKey, + window: &mut Window, + cx: &mut Context, + ) { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + + let path_list = project_group_key.path_list().clone(); + let host = project_group_key.host(); + let provisional_key = Some(project_group_key.clone()); + let active_workspace = multi_workspace.read(cx).workspace().clone(); + + let task = multi_workspace.update(cx, |this, cx| { + this.find_or_create_workspace( + path_list, + host, + provisional_key, + |options, window, cx| connect_remote(active_workspace, options, window, cx), + window, + cx, + ) + }); + + cx.spawn_in(window, async move |this, cx| { + let workspace = task.await?; + this.update_in(cx, |this, window, cx| { + this.create_new_thread(&workspace, window, cx); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + /// Rebuilds the sidebar contents from current workspace and thread state. /// /// Iterates [`MultiWorkspace::project_group_keys`] to determine project @@ -859,43 +859,21 @@ impl Sidebar { let query = self.filter_editor.read(cx).text(cx); // Derive active_entry from the active workspace's agent panel. - // Draft is checked first because a conversation can have a session_id - // before any messages are sent. However, a thread that's still loading - // also appears as a "draft" (no messages yet). + // A tracked draft (in `draft_threads`) is checked first via + // `active_draft_id`. Then we check for a thread with a session_id. + // If a thread is mid-load with no session_id yet, we fall back to + // `pending_remote_thread_activation` or keep the previous value. if let Some(active_ws) = &active_workspace { if let Some(panel) = active_ws.read(cx).panel::(cx) { - let active_thread_is_draft = panel.read(cx).active_thread_is_draft(cx); - let active_conversation_view = panel.read(cx).active_conversation_view(); - - if active_thread_is_draft || active_conversation_view.is_none() { - if active_conversation_view.is_none() - && let Some(session_id) = self.pending_remote_thread_activation.clone() - { - self.active_entry = Some(ActiveEntry::Thread { - session_id, - workspace: active_ws.clone(), - }); - } else { - let conversation_parent_id = - active_conversation_view.and_then(|cv| cv.read(cx).parent_id(cx)); - let preserving_thread = if let Some(ActiveEntry::Thread { - session_id, - .. - }) = &self.active_entry - { - self.active_entry_workspace() == Some(active_ws) - && conversation_parent_id - .as_ref() - .is_some_and(|id| id == session_id) - } else { - false - }; - if !preserving_thread { - self.active_entry = Some(ActiveEntry::Draft(active_ws.clone())); - } - } - } else if let Some(session_id) = - active_conversation_view.and_then(|cv| cv.read(cx).parent_id(cx)) + let panel = panel.read(cx); + if let Some(draft_id) = panel.active_draft_id() { + self.active_entry = Some(ActiveEntry::Draft { + id: draft_id, + workspace: active_ws.clone(), + }); + } else if let Some(session_id) = panel + .active_conversation_view() + .and_then(|cv| cv.read(cx).parent_id(cx)) { if self.pending_remote_thread_activation.as_ref() == Some(&session_id) { self.pending_remote_thread_activation = None; @@ -904,9 +882,14 @@ impl Sidebar { session_id, workspace: active_ws.clone(), }); + } else if let Some(session_id) = self.pending_remote_thread_activation.clone() { + self.active_entry = Some(ActiveEntry::Thread { + session_id, + workspace: active_ws.clone(), + }); } - // else: conversation exists, not a draft, but no session_id - // yet — thread is mid-load. Keep previous value. + // else: conversation is mid-load or panel is + // uninitialized — keep previous active_entry. } } @@ -1221,9 +1204,6 @@ impl Sidebar { entries.push(thread.into()); } } else { - let is_draft_for_group = is_active - && matches!(&self.active_entry, Some(ActiveEntry::Draft(ws)) if group_workspaces.contains(ws)); - project_header_indices.push(entries.len()); entries.push(ListEntry::ProjectHeader { key: group_key.clone(), @@ -1239,66 +1219,43 @@ impl Sidebar { continue; } - // Emit a DraftThread entry when the active draft belongs to this group. - if is_draft_for_group { - if let Some(ActiveEntry::Draft(draft_ws)) = &self.active_entry { - let ws_worktree_paths = ThreadWorktreePaths::from_project( - draft_ws.read(cx).project().read(cx), - cx, - ); - let worktrees = worktree_info_from_thread_paths(&ws_worktree_paths); - entries.push(ListEntry::DraftThread { - key: group_key.clone(), - workspace: None, - worktrees, - }); - } - } - - // Emit a DraftThread for each open linked worktree workspace - // that has no threads. Skip the specific workspace that is - // showing the active draft (it already has a DraftThread entry - // from the block above). + // Emit DraftThread entries by reading draft IDs from + // each workspace's AgentPanel in this group. { - let draft_ws_id = if is_draft_for_group { - self.active_entry.as_ref().and_then(|e| match e { - ActiveEntry::Draft(ws) => Some(ws.entity_id()), - _ => None, - }) - } else { - None - }; - let thread_store = ThreadMetadataStore::global(cx); + let mut group_draft_ids: Vec<(DraftId, Entity)> = Vec::new(); for ws in group_workspaces { - if Some(ws.entity_id()) == draft_ws_id { - continue; - } - let ws_worktree_paths = - ThreadWorktreePaths::from_project(ws.read(cx).project().read(cx), cx); - let has_linked_worktrees = - worktree_info_from_thread_paths(&ws_worktree_paths) - .iter() - .any(|wt| wt.kind == ui::WorktreeKind::Linked); - if !has_linked_worktrees { - continue; - } - let ws_path_list = workspace_path_list(ws, cx); - let store = thread_store.read(cx); - let has_threads = store.entries_for_path(&ws_path_list).next().is_some() - || store - .entries_for_main_worktree_path(&ws_path_list) - .next() - .is_some(); - if has_threads { - continue; + if let Some(panel) = ws.read(cx).panel::(cx) { + let ids = panel.read(cx).draft_ids(); + + for draft_id in ids { + group_draft_ids.push((draft_id, ws.clone())); + } } - let worktrees = worktree_info_from_thread_paths(&ws_worktree_paths); + } + // For empty groups with no drafts, emit a + // placeholder DraftThread. + if !has_threads && group_draft_ids.is_empty() { entries.push(ListEntry::DraftThread { + draft_id: None, key: group_key.clone(), - workspace: Some(ws.clone()), - worktrees, + workspace: group_workspaces.first().cloned(), + worktrees: Vec::new(), }); + } else { + for (draft_id, ws) in &group_draft_ids { + let ws_worktree_paths = ThreadWorktreePaths::from_project( + ws.read(cx).project().read(cx), + cx, + ); + let worktrees = worktree_info_from_thread_paths(&ws_worktree_paths); + entries.push(ListEntry::DraftThread { + draft_id: Some(*draft_id), + key: group_key.clone(), + workspace: Some(ws.clone()), + worktrees, + }); + } } } @@ -1457,15 +1414,34 @@ impl Sidebar { is_fully_expanded, } => self.render_view_more(ix, key, *is_fully_expanded, is_selected, cx), ListEntry::DraftThread { + draft_id, key, workspace, worktrees, } => { - if workspace.is_some() { - self.render_new_thread(ix, key, worktrees, workspace.as_ref(), is_selected, cx) - } else { - self.render_draft_thread(ix, is_active, worktrees, is_selected, cx) - } + let group_has_threads = self + .contents + .entries + .iter() + .any(|e| matches!(e, ListEntry::ProjectHeader { key: hk, has_threads: true, .. } if hk == key)); + // Count drafts in the AgentPanel for this group's workspaces. + let sibling_draft_count = workspace + .as_ref() + .and_then(|ws| ws.read(cx).panel::(cx)) + .map(|p| p.read(cx).draft_ids().len()) + .unwrap_or(0); + let can_dismiss = group_has_threads || sibling_draft_count > 1; + self.render_draft_thread( + ix, + *draft_id, + key, + workspace.as_ref(), + is_active, + worktrees, + is_selected, + can_dismiss, + cx, + ) } }; @@ -1533,17 +1509,6 @@ impl Sidebar { (IconName::ChevronDown, "Collapse Project") }; - let has_new_thread_entry = self - .contents - .entries - .get(ix + 1) - .is_some_and(|entry| matches!(entry, ListEntry::DraftThread { .. })); - let show_new_thread_button = !has_new_thread_entry && !self.has_filter_query(cx); - let workspace = self.multi_workspace.upgrade().and_then(|mw| { - mw.read(cx) - .workspace_for_paths(key.path_list(), key.host().as_ref(), cx) - }); - let key_for_toggle = key.clone(); let key_for_collapse = key.clone(); let view_more_expanded = self.expanded_groups.contains_key(key); @@ -1559,9 +1524,26 @@ impl Sidebar { }; let color = cx.theme().colors(); - let hover_color = color + let sidebar_base_bg = color + .title_bar_background + .blend(color.panel_background.opacity(0.25)); + + let base_bg = color.background.blend(sidebar_base_bg); + + let hover_base = color .element_active .blend(color.element_background.opacity(0.2)); + let hover_solid = base_bg.blend(hover_base); + let real_hover_color = if is_active { base_bg } else { hover_solid }; + + let group_name_for_gradient = group_name.clone(); + let gradient_overlay = move || { + GradientFade::new(base_bg, real_hover_color, real_hover_color) + .width(px(64.0)) + .right(px(-2.0)) + .gradient_stop(0.75) + .group_name(group_name_for_gradient.clone()) + }; let is_ellipsis_menu_open = self.project_header_menu_ix == Some(ix); @@ -1569,9 +1551,11 @@ impl Sidebar { .id(id) .group(&group_name) .h(Tab::content_height(cx)) + .relative() .w_full() .pl(px(5.)) .pr_1p5() + .justify_between() .border_1() .map(|this| { if is_focused { @@ -1580,7 +1564,6 @@ impl Sidebar { this.border_color(gpui::transparent_black()) } }) - .justify_between() .child( h_flex() .relative() @@ -1633,11 +1616,13 @@ impl Sidebar { }) }), ) + .child(gradient_overlay()) .child( h_flex() .when(!is_ellipsis_menu_open, |this| { this.visible_on_hover(&group_name) }) + .child(gradient_overlay()) .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| { cx.stop_propagation(); }) @@ -1663,37 +1648,54 @@ impl Sidebar { })), ) }) - .when_some( - workspace.filter(|_| show_new_thread_button), - |this, workspace| { - let key = key.clone(); - let focus_handle = self.focus_handle.clone(); - this.child( - IconButton::new( - SharedString::from(format!( - "{id_prefix}project-header-new-thread-{ix}", - )), - IconName::Plus, - ) - .icon_size(IconSize::Small) - .tooltip(move |_, cx| { - Tooltip::for_action_in( - "New Thread", - &NewThread, - &focus_handle, - cx, - ) - }) - .on_click(cx.listener( - move |this, _, window, cx| { - this.collapsed_groups.remove(&key); - this.selection = None; - this.create_new_thread(&workspace, window, cx); - }, - )), + .child({ + let key = key.clone(); + let focus_handle = self.focus_handle.clone(); + + IconButton::new( + SharedString::from(format!( + "{id_prefix}project-header-new-thread-{ix}", + )), + IconName::Plus, + ) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action_in( + "Start New Agent Thread", + &NewThread, + &focus_handle, + cx, ) - }, - ), + }) + .on_click(cx.listener( + move |this, _, window, cx| { + this.collapsed_groups.remove(&key); + this.selection = None; + // If the active workspace belongs to this + // group, use it (preserves linked worktree + // context). Otherwise resolve from the key. + let workspace = this.multi_workspace.upgrade().and_then(|mw| { + let mw = mw.read(cx); + let active = mw.workspace().clone(); + let active_key = active.read(cx).project_group_key(cx); + if active_key == key { + Some(active) + } else { + mw.workspace_for_paths( + key.path_list(), + key.host().as_ref(), + cx, + ) + } + }); + if let Some(workspace) = workspace { + this.create_new_thread(&workspace, window, cx); + } else { + this.open_workspace_and_create_draft(&key, window, cx); + } + }, + )) + }), ) .map(|this| { if !has_threads && is_active { @@ -1701,7 +1703,7 @@ impl Sidebar { } else { let key = key.clone(); this.cursor_pointer() - .when(!is_active, |this| this.hover(|s| s.bg(hover_color))) + .when(!is_active, |this| this.hover(|s| s.bg(hover_solid))) .tooltip(Tooltip::text("Open Workspace")) .on_click(cx.listener(move |this, _, window, cx| { if let Some(workspace) = this.multi_workspace.upgrade().and_then(|mw| { @@ -1711,12 +1713,11 @@ impl Sidebar { cx, ) }) { - this.active_entry = Some(ActiveEntry::Draft(workspace.clone())); - if let Some(multi_workspace) = this.multi_workspace.upgrade() { - multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.activate(workspace.clone(), window, cx); - }); - } + // Just activate the workspace. The + // AgentPanel remembers what was last + // shown, so the user returns to whatever + // thread/draft they were looking at. + this.activate_workspace(&workspace, window, cx); if AgentPanel::is_visible(&workspace, cx) { workspace.update(cx, |workspace, cx| { workspace.focus_panel::(window, cx); @@ -2165,16 +2166,21 @@ impl Sidebar { self.expand_thread_group(&key, cx); } } - ListEntry::DraftThread { key, workspace, .. } => { + ListEntry::DraftThread { + draft_id, + key, + workspace, + .. + } => { + let draft_id = *draft_id; let key = key.clone(); let workspace = workspace.clone(); - if let Some(workspace) = workspace.or_else(|| { - self.multi_workspace.upgrade().and_then(|mw| { - mw.read(cx) - .workspace_for_paths(key.path_list(), key.host().as_ref(), cx) - }) - }) { - self.create_new_thread(&workspace, window, cx); + if let Some(draft_id) = draft_id { + if let Some(workspace) = workspace { + self.activate_draft(draft_id, &workspace, window, cx); + } + } else if let Some(workspace) = workspace { + self.activate_workspace(&workspace, window, cx); } else { self.open_workspace_for_group(&key, window, cx); } @@ -2352,10 +2358,10 @@ impl Sidebar { }; let pending_session_id = metadata.session_id.clone(); - let is_remote = project_group_key.host().is_some(); - if is_remote { - self.pending_remote_thread_activation = Some(pending_session_id.clone()); - } + // Mark the pending thread activation so rebuild_contents + // preserves the Thread active_entry during loading (prevents + // spurious draft flash). + self.pending_remote_thread_activation = Some(pending_session_id.clone()); let host = project_group_key.host(); let provisional_key = Some(project_group_key.clone()); @@ -2379,7 +2385,7 @@ impl Sidebar { // failures or cancellations do not leave a stale connection modal behind. remote_connection::dismiss_connection_modal(&modal_workspace, cx); - if result.is_err() || is_remote { + if result.is_err() { this.update(cx, |this, _cx| { if this.pending_remote_thread_activation.as_ref() == Some(&pending_session_id) { this.pending_remote_thread_activation = None; @@ -2813,22 +2819,20 @@ impl Sidebar { .entries_for_path(folder_paths) .filter(|t| t.session_id != *session_id) .count(); + if remaining > 0 { return None; } let multi_workspace = self.multi_workspace.upgrade()?; - // Thread metadata doesn't carry host info yet, so we pass - // `None` here. This may match a local workspace with the same - // paths instead of the intended remote one. let workspace = multi_workspace .read(cx) .workspace_for_paths(folder_paths, None, cx)?; - // Don't remove the main worktree workspace — the project - // header always provides access to it. let group_key = workspace.read(cx).project_group_key(cx); - (group_key.path_list() != folder_paths).then_some(workspace) + let is_linked_worktree = group_key.path_list() != folder_paths; + + is_linked_worktree.then_some(workspace) }); if let Some(workspace_to_remove) = workspace_to_remove { @@ -2881,7 +2885,6 @@ impl Sidebar { }) .detach_and_log_err(cx); } else { - // Simple case: no workspace removal needed. let neighbor_metadata = neighbor.map(|(metadata, _)| metadata); let in_flight = self.start_archive_worktree_task(session_id, roots_to_archive, cx); self.archive_and_activate( @@ -2947,7 +2950,11 @@ impl Sidebar { .is_some_and(|id| id == *session_id); if panel_shows_archived { panel.update(cx, |panel, cx| { - panel.clear_active_thread(window, cx); + // Replace the archived thread with a + // tracked draft so the panel isn't left + // in Uninitialized state. + let id = panel.create_draft(window, cx); + panel.activate_draft(id, false, window, cx); }); } } @@ -2960,6 +2967,7 @@ impl Sidebar { // tell the panel to load it and activate that workspace. // `rebuild_contents` will reconcile `active_entry` once the thread // finishes loading. + if let Some(metadata) = neighbor { if let Some(workspace) = self.multi_workspace.upgrade().and_then(|mw| { mw.read(cx) @@ -2974,26 +2982,24 @@ impl Sidebar { // No neighbor or its workspace isn't open — fall back to a new // draft. Use the group workspace (main project) rather than the // active entry workspace, which may be a linked worktree that is - // about to be cleaned up. + // about to be cleaned up or already removed. let fallback_workspace = thread_folder_paths .and_then(|folder_paths| { let mw = self.multi_workspace.upgrade()?; let mw = mw.read(cx); - // Find the group's main workspace (whose root paths match - // the project group key, not the thread's folder paths). let thread_workspace = mw.workspace_for_paths(folder_paths, None, cx)?; let group_key = thread_workspace.read(cx).project_group_key(cx); mw.workspace_for_paths(group_key.path_list(), None, cx) }) - .or_else(|| self.active_entry_workspace().cloned()); + .or_else(|| { + self.multi_workspace + .upgrade() + .map(|mw| mw.read(cx).workspace().clone()) + }); if let Some(workspace) = fallback_workspace { self.activate_workspace(&workspace, window, cx); - if let Some(panel) = workspace.read(cx).panel::(cx) { - panel.update(cx, |panel, cx| { - panel.new_thread(&NewThread, window, cx); - }); - } + self.create_new_thread(&workspace, window, cx); } } @@ -3120,35 +3126,18 @@ impl Sidebar { self.archive_thread(&session_id, window, cx); } Some(ListEntry::DraftThread { + draft_id: Some(draft_id), workspace: Some(workspace), .. }) => { - self.remove_worktree_workspace(workspace.clone(), window, cx); + let draft_id = *draft_id; + let workspace = workspace.clone(); + self.remove_draft(draft_id, &workspace, window, cx); } _ => {} } } - fn remove_worktree_workspace( - &mut self, - workspace: Entity, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(multi_workspace) = self.multi_workspace.upgrade() { - multi_workspace - .update(cx, |mw, cx| { - mw.remove( - [workspace], - |this, _window, _cx| gpui::Task::ready(Ok(this.workspace().clone())), - window, - cx, - ) - }) - .detach_and_log_err(cx); - } - } - fn record_thread_access(&mut self, session_id: &acp::SessionId) { self.thread_last_accessed .insert(session_id.clone(), Utc::now()); @@ -3687,30 +3676,13 @@ impl Sidebar { // If there is a keyboard selection, walk backwards through // `project_header_indices` to find the header that owns the selected // row. Otherwise fall back to the active workspace. - let workspace = if let Some(selected_ix) = self.selection { - self.contents - .project_header_indices - .iter() - .rev() - .find(|&&header_ix| header_ix <= selected_ix) - .and_then(|&header_ix| match &self.contents.entries[header_ix] { - ListEntry::ProjectHeader { key, .. } => { - self.multi_workspace.upgrade().and_then(|mw| { - mw.read(cx).workspace_for_paths( - key.path_list(), - key.host().as_ref(), - cx, - ) - }) - } - _ => None, - }) - } else { - // Use the currently active workspace. - self.multi_workspace - .upgrade() - .map(|mw| mw.read(cx).workspace().clone()) - }; + // Always use the currently active workspace so that drafts + // are created in the linked worktree the user is focused on, + // not the main worktree resolved from the project header. + let workspace = self + .multi_workspace + .upgrade() + .map(|mw| mw.read(cx).workspace().clone()); let Some(workspace) = workspace else { return; @@ -3729,20 +3701,166 @@ impl Sidebar { return; }; - self.active_entry = Some(ActiveEntry::Draft(workspace.clone())); - multi_workspace.update(cx, |multi_workspace, cx| { multi_workspace.activate(workspace.clone(), window, cx); }); - workspace.update(cx, |workspace, cx| { - if let Some(agent_panel) = workspace.panel::(cx) { - agent_panel.update(cx, |panel, cx| { - panel.new_thread(&NewThread, window, cx); + let draft_id = workspace.update(cx, |workspace, cx| { + let panel = workspace.panel::(cx)?; + let draft_id = panel.update(cx, |panel, cx| { + let id = panel.create_draft(window, cx); + panel.activate_draft(id, true, window, cx); + id + }); + workspace.focus_panel::(window, cx); + Some(draft_id) + }); + + if let Some(draft_id) = draft_id { + self.active_entry = Some(ActiveEntry::Draft { + id: draft_id, + workspace: workspace.clone(), + }); + } + } + + fn activate_draft( + &mut self, + draft_id: DraftId, + workspace: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(multi_workspace) = self.multi_workspace.upgrade() { + multi_workspace.update(cx, |mw, cx| { + mw.activate(workspace.clone(), window, cx); + }); + } + + workspace.update(cx, |ws, cx| { + if let Some(panel) = ws.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.activate_draft(draft_id, true, window, cx); + }); + } + ws.focus_panel::(window, cx); + }); + + self.active_entry = Some(ActiveEntry::Draft { + id: draft_id, + workspace: workspace.clone(), + }); + + self.observe_draft_editor(cx); + } + + fn remove_draft( + &mut self, + draft_id: DraftId, + workspace: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + workspace.update(cx, |ws, cx| { + if let Some(panel) = ws.panel::(cx) { + panel.update(cx, |panel, _cx| { + panel.remove_draft(draft_id); + }); + } + }); + + let was_active = self + .active_entry + .as_ref() + .is_some_and(|e| e.is_active_draft(draft_id)); + + if was_active { + let mut switched = false; + let group_key = workspace.read(cx).project_group_key(cx); + + // Try the next draft below in the sidebar (smaller ID + // since the list is newest-first). Fall back to the one + // above (larger ID) if the deleted draft was last. + if let Some(panel) = workspace.read(cx).panel::(cx) { + let ids = panel.read(cx).draft_ids(); + let sibling = ids + .iter() + .find(|id| id.0 < draft_id.0) + .or_else(|| ids.first()); + if let Some(&sibling_id) = sibling { + self.activate_draft(sibling_id, workspace, window, cx); + switched = true; + } + } + + // No sibling draft — try the first thread in the group. + if !switched { + let first_thread = self.contents.entries.iter().find_map(|entry| { + if let ListEntry::Thread(thread) = entry { + if let ThreadEntryWorkspace::Open(ws) = &thread.workspace { + if ws.read(cx).project_group_key(cx) == group_key { + return Some((thread.metadata.clone(), ws.clone())); + } + } + } + None + }); + if let Some((metadata, ws)) = first_thread { + self.activate_thread(metadata, &ws, false, window, cx); + switched = true; + } + } + + if !switched { + self.active_entry = None; + } + } + + self.update_entries(cx); + } + + fn clear_draft( + &mut self, + draft_id: DraftId, + workspace: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + workspace.update(cx, |ws, cx| { + if let Some(panel) = ws.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.clear_draft_editor(draft_id, window, cx); }); } - workspace.focus_panel::(window, cx); }); + self.update_entries(cx); + } + + /// Cleans, collapses whitespace, and truncates raw editor text + /// for display as a draft label in the sidebar. + fn truncate_draft_label(raw: &str) -> Option { + let cleaned = Self::clean_mention_links(raw); + let mut text: String = cleaned.split_whitespace().collect::>().join(" "); + if text.is_empty() { + return None; + } + const MAX_CHARS: usize = 250; + if let Some((truncate_at, _)) = text.char_indices().nth(MAX_CHARS) { + text.truncate(truncate_at); + } + Some(text.into()) + } + + /// Reads a draft's prompt text from its ConversationView in the AgentPanel. + fn read_draft_text( + &self, + draft_id: DraftId, + workspace: &Entity, + cx: &App, + ) -> Option { + let panel = workspace.read(cx).panel::(cx)?; + let raw = panel.read(cx).draft_editor_text(draft_id, cx)?; + Self::truncate_draft_label(&raw) } fn active_project_group_key(&self, cx: &App) -> Option { @@ -3978,111 +4096,122 @@ impl Sidebar { fn render_draft_thread( &self, ix: usize, + draft_id: Option, + key: &ProjectGroupKey, + workspace: Option<&Entity>, is_active: bool, worktrees: &[WorktreeInfo], is_selected: bool, + can_dismiss: bool, cx: &mut Context, ) -> AnyElement { - let label: SharedString = if is_active { - self.active_draft_text(cx) - .unwrap_or_else(|| "New Thread".into()) - } else { - "New Thread".into() - }; + let label: SharedString = draft_id + .and_then(|id| workspace.and_then(|ws| self.read_draft_text(id, ws, cx))) + .unwrap_or_else(|| "New Agent Thread".into()); let id = SharedString::from(format!("draft-thread-btn-{}", ix)); - let thread_item = ThreadItem::new(id, label) - .icon(IconName::Plus) - .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.8))) - .worktrees( - worktrees - .iter() - .map(|wt| ThreadItemWorktreeInfo { - name: wt.name.clone(), - full_path: wt.full_path.clone(), - highlight_positions: wt.highlight_positions.clone(), - kind: wt.kind, - }) - .collect(), - ) - .selected(true) - .focused(is_selected) - .on_click(cx.listener(|this, _, window, cx| { - if let Some(workspace) = this.active_workspace(cx) { - if !AgentPanel::is_visible(&workspace, cx) { - workspace.update(cx, |workspace, cx| { - workspace.focus_panel::(window, cx); - }); - } - } - })); - - div() - .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| { - cx.stop_propagation(); + let worktrees = worktrees + .iter() + .map(|worktree| ThreadItemWorktreeInfo { + name: worktree.name.clone(), + full_path: worktree.full_path.clone(), + highlight_positions: worktree.highlight_positions.clone(), + kind: worktree.kind, }) - .child(thread_item) - .into_any_element() - } + .collect(); - fn render_new_thread( - &self, - ix: usize, - key: &ProjectGroupKey, - worktrees: &[WorktreeInfo], - workspace: Option<&Entity>, - is_selected: bool, - cx: &mut Context, - ) -> AnyElement { - let label: SharedString = DEFAULT_THREAD_TITLE.into(); - let key = key.clone(); + let is_hovered = self.hovered_thread_index == Some(ix); - let id = SharedString::from(format!("new-thread-btn-{}", ix)); + let key = key.clone(); + let workspace_for_click = workspace.cloned(); + let workspace_for_remove = workspace.cloned(); + let workspace_for_clear = workspace.cloned(); - let mut thread_item = ThreadItem::new(id, label) - .icon(IconName::Plus) - .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.8))) - .worktrees( - worktrees - .iter() - .map(|wt| ThreadItemWorktreeInfo { - name: wt.name.clone(), - full_path: wt.full_path.clone(), - highlight_positions: wt.highlight_positions.clone(), - kind: wt.kind, - }) - .collect(), - ) - .selected(false) + ThreadItem::new(id, label) + .icon(IconName::Pencil) + .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.4))) + .worktrees(worktrees) + .selected(is_active) .focused(is_selected) + .hovered(is_hovered) + .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| { + if *is_hovered { + this.hovered_thread_index = Some(ix); + } else if this.hovered_thread_index == Some(ix) { + this.hovered_thread_index = None; + } + cx.notify(); + })) .on_click(cx.listener(move |this, _, window, cx| { - this.selection = None; - if let Some(workspace) = this.multi_workspace.upgrade().and_then(|mw| { - mw.read(cx) - .workspace_for_paths(key.path_list(), key.host().as_ref(), cx) - }) { - this.create_new_thread(&workspace, window, cx); + if let Some(draft_id) = draft_id { + if let Some(workspace) = &workspace_for_click { + this.activate_draft(draft_id, workspace, window, cx); + } + } else if let Some(workspace) = &workspace_for_click { + // Placeholder with an open workspace — just + // activate it. The panel remembers its last view. + this.activate_workspace(workspace, window, cx); + if AgentPanel::is_visible(workspace, cx) { + workspace.update(cx, |ws, cx| { + ws.focus_panel::(window, cx); + }); + } } else { + // No workspace at all — just open one. The + // panel's load fallback will create a draft. this.open_workspace_for_group(&key, window, cx); } - })); - - // Linked worktree DraftThread entries can be dismissed, which removes - // the workspace from the multi-workspace. - if let Some(workspace) = workspace.cloned() { - thread_item = thread_item.action_slot( - IconButton::new("close-worktree-workspace", IconName::Close) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(Tooltip::text("Close Workspace")) - .on_click(cx.listener(move |this, _, window, cx| { - this.remove_worktree_workspace(workspace.clone(), window, cx); - })), - ); - } - - thread_item.into_any_element() + })) + .when_some(draft_id.filter(|_| can_dismiss), |this, draft_id| { + this.action_slot( + div() + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .child( + IconButton::new( + SharedString::from(format!("close-draft-{}", ix)), + IconName::Close, + ) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Remove Draft")) + .on_click(cx.listener( + move |this, _, window, cx| { + if let Some(workspace) = &workspace_for_remove { + this.remove_draft(draft_id, workspace, window, cx); + } + }, + )), + ), + ) + }) + .when_some(draft_id.filter(|_| !can_dismiss), |this, draft_id| { + this.action_slot( + div() + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .child( + IconButton::new( + SharedString::from(format!("clear-draft-{}", ix)), + IconName::Close, + ) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Clear Draft")) + .on_click(cx.listener( + move |this, _, window, cx| { + if let Some(workspace) = &workspace_for_clear { + this.clear_draft(draft_id, workspace, window, cx); + } + }, + )), + ), + ) + }) + .into_any_element() } fn render_no_results(&self, cx: &mut Context) -> impl IntoElement { @@ -4389,8 +4518,7 @@ impl Sidebar { } fn render_acp_import_onboarding(&mut self, cx: &mut Context) -> impl IntoElement { - let description = - "Import threads from your ACP agents — whether started in Zed or another client."; + let description = "Import threads from agents like Claude Agent, Codex, and more, whether started in Zed or another client."; let bg = cx.theme().colors().text_accent; @@ -4411,7 +4539,7 @@ impl Sidebar { .w_full() .gap_1() .justify_between() - .child(Label::new("Looking for ACP threads?")) + .child(Label::new("Looking for threads from external agents?")) .child( IconButton::new("close-onboarding", IconName::Close) .icon_size(IconSize::Small) @@ -4420,7 +4548,7 @@ impl Sidebar { ) .child(Label::new(description).color(Color::Muted).mb_2()) .child( - Button::new("import-acp", "Import ACP Threads") + Button::new("import-acp", "Import Threads") .full_width() .style(ButtonStyle::OutlinedCustom(cx.theme().colors().border)) .label_size(LabelSize::Small) diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index ea4ec36674878ca958a2f73af0adf749a40157f6..0b197b5fd278bbdf19b4c30fe27e1d591ad29696 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -45,7 +45,7 @@ fn assert_active_thread(sidebar: &Sidebar, session_id: &acp::SessionId, msg: &st #[track_caller] fn assert_active_draft(sidebar: &Sidebar, workspace: &Entity, msg: &str) { assert!( - matches!(&sidebar.active_entry, Some(ActiveEntry::Draft(ws)) if ws == workspace), + matches!(&sidebar.active_entry, Some(ActiveEntry::Draft { workspace: ws, .. }) if ws == workspace), "{msg}: expected active_entry to be Draft for workspace {:?}, got {:?}", workspace.entity_id(), sidebar.active_entry, @@ -340,11 +340,6 @@ fn visible_entries_as_strings( } else { "" }; - let is_active = sidebar - .active_entry - .as_ref() - .is_some_and(|active| active.matches_entry(entry)); - let active_indicator = if is_active { " (active)" } else { "" }; match entry { ListEntry::ProjectHeader { label, @@ -377,7 +372,7 @@ fn visible_entries_as_strings( "" }; let worktree = format_linked_worktree_chips(&thread.worktrees); - format!(" {title}{worktree}{live}{status_str}{notified}{active_indicator}{selected}") + format!(" {title}{worktree}{live}{status_str}{notified}{selected}") } ListEntry::ViewMore { is_fully_expanded, .. @@ -388,17 +383,14 @@ fn visible_entries_as_strings( format!(" + View More{}", selected) } } - ListEntry::DraftThread { - workspace, - worktrees, - .. - } => { + ListEntry::DraftThread { worktrees, .. } => { let worktree = format_linked_worktree_chips(worktrees); - if workspace.is_some() { - format!(" [+ New Thread{}]{}", worktree, selected) - } else { - format!(" [~ Draft{}]{}{}", worktree, active_indicator, selected) - } + let is_active = sidebar + .active_entry + .as_ref() + .is_some_and(|e| e.matches_entry(entry)); + let active_marker = if is_active { " *" } else { "" }; + format!(" [~ Draft{}]{}{}", worktree, active_marker, selected) } } }) @@ -566,10 +558,7 @@ async fn test_single_workspace_no_threads(cx: &mut TestAppContext) { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - // - "v [my-project]", - ] + vec!["v [my-project]", " [~ Draft]"] ); } @@ -1329,13 +1318,10 @@ async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) { cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - // An empty project has only the header. + // An empty project has the header and an auto-created draft. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - // - "v [empty-project]", - ] + vec!["v [empty-project]", " [~ Draft]"] ); // Focus sidebar — focus_in does not set a selection @@ -1346,7 +1332,11 @@ async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) { cx.dispatch_action(SelectNext); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - // At the end (only one entry), wraps back to first entry + // SelectNext advances to index 1 (draft entry) + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + // At the end (two entries), wraps back to first entry cx.dispatch_action(SelectNext); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); @@ -1470,7 +1460,7 @@ async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) { vec![ // "v [my-project]", - " Hello * (active)", + " Hello *", " Hello * (running)", ] ); @@ -1568,7 +1558,7 @@ async fn test_background_thread_completion_triggers_notification(cx: &mut TestAp vec![ // "v [project-a]", - " Hello * (running) (active)", + " Hello * (running)", ] ); @@ -1582,7 +1572,7 @@ async fn test_background_thread_completion_triggers_notification(cx: &mut TestAp vec![ // "v [project-a]", - " Hello * (!) (active)", + " Hello * (!)", ] ); } @@ -2274,7 +2264,7 @@ async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) vec![ // "v [my-project]", - " Hello * (active)", + " Hello *", ] ); @@ -2300,7 +2290,7 @@ async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) vec![ // "v [my-project]", - " Friendly Greeting with AI * (active)", + " Friendly Greeting with AI *", ] ); } @@ -2558,7 +2548,7 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex vec![ // "v [project-a]", - " Hello * (active)", + " Hello *", ] ); @@ -2591,9 +2581,8 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ - // - "v [project-a, project-b]", - " Hello * (active)", + "v [project-a, project-b]", // + " Hello *", ] ); @@ -3126,7 +3115,6 @@ async fn test_worktree_collision_keeps_active_workspace(cx: &mut TestAppContext) vec![ // "v [project-a, project-b]", - " [~ Draft] (active)", " Thread B", "v [project-a]", " Thread A", @@ -3207,7 +3195,6 @@ async fn test_worktree_collision_keeps_active_workspace(cx: &mut TestAppContext) vec![ // "v [project-a, project-b]", - " [~ Draft] (active)", " Thread A", " Worktree Thread {project-a:wt-feature}", " Thread B", @@ -3327,7 +3314,6 @@ async fn test_worktree_add_syncs_linked_worktree_sibling(cx: &mut TestAppContext vec![ // "v [project]", - " [~ Draft {wt-feature}] (active)", " Worktree Thread {wt-feature}", " Main Thread", ] @@ -3386,7 +3372,6 @@ async fn test_worktree_add_syncs_linked_worktree_sibling(cx: &mut TestAppContext vec![ // "v [other-project, project]", - " [~ Draft {project:wt-feature}] (active)", " Worktree Thread {project:wt-feature}", " Main Thread", ] @@ -3421,7 +3406,7 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) { vec![ // "v [my-project]", - " Hello * (active)", + " Hello *", ] ); @@ -3437,12 +3422,7 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - // - "v [my-project]", - " [~ Draft] (active)", - " Hello *", - ], + vec!["v [my-project]", " [~ Draft] *", " Hello *"], "After Cmd-N the sidebar should show a highlighted Draft entry" ); @@ -3478,25 +3458,20 @@ async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext) vec![ // "v [my-project]", - " Hello * (active)", + " Hello *", ] ); - // Open a new draft thread via a server connection. This gives the - // conversation a parent_id (session assigned by the server) but - // no messages have been sent, so active_thread_is_draft() is true. - let draft_connection = StubAgentConnection::new(); - open_thread_with_connection(&panel, draft_connection, cx); + // Create a new draft via Cmd-N. Since new_thread() now creates a + // tracked draft in the AgentPanel, it appears in the sidebar. + panel.update_in(cx, |panel, window, cx| { + panel.new_thread(&NewThread, window, cx); + }); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - // - "v [my-project]", - " [~ Draft] (active)", - " Hello *", - ], + vec!["v [my-project]", " [~ Draft] *", " Hello *"], ); let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); @@ -3509,6 +3484,80 @@ async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext) }); } +#[gpui::test] +async fn test_sending_message_from_draft_removes_draft(cx: &mut TestAppContext) { + // When the user sends a message from a draft thread, the draft + // should be removed from the sidebar and the active_entry should + // transition to a Thread pointing at the new session. + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + // Create a saved thread so the group isn't empty. + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + let existing_session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&existing_session_id, &project, cx).await; + cx.run_until_parked(); + + // Create a draft via Cmd-N. + panel.update_in(cx, |panel, window, cx| { + panel.new_thread(&NewThread, window, cx); + }); + cx.run_until_parked(); + + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " [~ Draft] *", " Hello *"], + "draft should be visible before sending", + ); + sidebar.read_with(cx, |sidebar, _| { + assert_active_draft(sidebar, &workspace, "should be on draft before sending"); + }); + + // Simulate what happens when a draft sends its first message: + // the AgentPanel's MessageSentOrQueued handler removes the draft + // from `draft_threads`, then the sidebar rebuilds. We can't use + // the NativeAgentServer in tests, so replicate the key steps: + // remove the draft, open a real thread with a stub connection, + // and send. + let draft_id = panel.read_with(cx, |panel, _| panel.active_draft_id().unwrap()); + panel.update_in(cx, |panel, _window, _cx| { + panel.remove_draft(draft_id); + }); + let draft_connection = StubAgentConnection::new(); + draft_connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("World".into()), + )]); + open_thread_with_connection(&panel, draft_connection, cx); + send_message(&panel, cx); + let new_session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&new_session_id, &project, cx).await; + cx.run_until_parked(); + + // The draft should be gone and the new thread should be active. + let entries = visible_entries_as_strings(&sidebar, cx); + let draft_count = entries.iter().filter(|e| e.contains("Draft")).count(); + assert_eq!( + draft_count, 0, + "draft should be removed after sending a message" + ); + + sidebar.read_with(cx, |sidebar, _| { + assert_active_thread( + sidebar, + &new_session_id, + "active_entry should transition to the new thread after sending", + ); + }); +} + #[gpui::test] async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) { // When the active workspace is an absorbed git worktree, cmd-n @@ -3593,7 +3642,7 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp vec![ // "v [project]", - " Hello {wt-feature-a} * (active)", + " Hello {wt-feature-a} *", ] ); @@ -3611,8 +3660,8 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp vec![ // "v [project]", - " [~ Draft {wt-feature-a}] (active)", - " Hello {wt-feature-a} *", + " [~ Draft {wt-feature-a}] *", + " Hello {wt-feature-a} *" ], "After Cmd-N in an absorbed worktree, the sidebar should show \ a highlighted Draft entry under the main repo header" @@ -3729,11 +3778,7 @@ async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) { // The chip name is derived from the path even before git discovery. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - // - "v [project]", - " Worktree Thread {rosewood}", - ] + vec!["v [project]", " Worktree Thread {rosewood}"] ); // Now add the worktree to the git state and trigger a rescan. @@ -3925,12 +3970,7 @@ async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut // appears as a "New Thread" button with its worktree chip. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - // - "v [project]", - " [+ New Thread {wt-feature-b}]", - " Thread A {wt-feature-a}", - ] + vec!["v [project]", " Thread A {wt-feature-a}",] ); } @@ -4184,12 +4224,7 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp let entries = visible_entries_as_strings(&sidebar, cx); assert_eq!( entries, - vec![ - // - "v [project]", - " [~ Draft] (active)", - " Hello {wt-feature-a} * (running)", - ] + vec!["v [project]", " Hello {wt-feature-a} * (running)",] ); } @@ -4272,12 +4307,7 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - // - "v [project]", - " [~ Draft] (active)", - " Hello {wt-feature-a} * (running)", - ] + vec!["v [project]", " Hello {wt-feature-a} * (running)",] ); connection.end_turn(session_id, acp::StopReason::EndTurn); @@ -4285,12 +4315,7 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - // - "v [project]", - " [~ Draft] (active)", - " Hello {wt-feature-a} * (!)", - ] + vec!["v [project]", " Hello {wt-feature-a} * (!)",] ); } @@ -5498,6 +5523,7 @@ async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut Test vec![ // "v [other, project]", + " [~ Draft]", "v [project]", " Worktree Thread {wt-feature-a}", ] @@ -5931,6 +5957,12 @@ async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) { let panel_b = add_agent_panel(&workspace_b, cx); cx.run_until_parked(); + // Explicitly create a draft on workspace_b so the sidebar tracks one. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.create_new_thread(&workspace_b, window, cx); + }); + cx.run_until_parked(); + // --- Scenario 1: archive a thread in the non-active workspace --- // Create a thread in project-a (non-active — project-b is active). @@ -5951,7 +5983,7 @@ async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) { // active_entry should still be a draft on workspace_b (the active one). sidebar.read_with(cx, |sidebar, _| { assert!( - matches!(&sidebar.active_entry, Some(ActiveEntry::Draft(ws)) if ws == &workspace_b), + matches!(&sidebar.active_entry, Some(ActiveEntry::Draft { workspace: ws, .. }) if ws == &workspace_b), "expected Draft(workspace_b) after archiving non-active thread, got: {:?}", sidebar.active_entry, ); @@ -5986,7 +6018,7 @@ async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) { // Should fall back to a draft on the same workspace. sidebar.read_with(cx, |sidebar, _| { assert!( - matches!(&sidebar.active_entry, Some(ActiveEntry::Draft(ws)) if ws == &workspace_b), + matches!(&sidebar.active_entry, Some(ActiveEntry::Draft { workspace: ws, .. }) if ws == &workspace_b), "expected Draft(workspace_b) after archiving active thread, got: {:?}", sidebar.active_entry, ); @@ -5996,9 +6028,8 @@ async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) { #[gpui::test] async fn test_switch_to_workspace_with_archived_thread_shows_draft(cx: &mut TestAppContext) { // When a thread is archived while the user is in a different workspace, - // the archiving code clears the thread from its panel (via - // `clear_active_thread`). Switching back to that workspace should show - // a draft, not the archived thread. + // the archiving code replaces the thread with a tracked draft in its + // panel. Switching back to that workspace should show the draft. agent_ui::test_support::init_test(cx); cx.update(|cx| { ThreadStore::init_global(cx); @@ -6059,7 +6090,7 @@ async fn test_switch_to_workspace_with_archived_thread_shows_draft(cx: &mut Test sidebar.read_with(cx, |sidebar, _| { assert!( - matches!(&sidebar.active_entry, Some(ActiveEntry::Draft(ws)) if ws == &workspace_a), + matches!(&sidebar.active_entry, Some(ActiveEntry::Draft { workspace: ws, .. }) if ws == &workspace_a), "expected Draft(workspace_a) after switching to workspace with archived thread, got: {:?}", sidebar.active_entry, ); @@ -6561,9 +6592,10 @@ async fn test_archive_thread_on_linked_worktree_selects_sibling_thread(cx: &mut #[gpui::test] async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestAppContext) { // When a linked worktree is opened as its own workspace and the user - // switches away, the workspace must still be reachable from a DraftThread - // sidebar entry. Pressing RemoveSelectedThread (shift-backspace) on that - // entry should remove the workspace. + // creates a draft thread from it, then switches away, the workspace must + // still be reachable from that DraftThread sidebar entry. Pressing + // RemoveSelectedThread (shift-backspace) on that entry should remove the + // workspace. init_test(cx); let fs = FakeFs::new(cx.executor()); @@ -6627,6 +6659,14 @@ async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestA add_agent_panel(&worktree_workspace, cx); cx.run_until_parked(); + // Explicitly create a draft thread from the linked worktree workspace. + // Auto-created drafts use the group's first workspace (the main one), + // so a user-created draft is needed to make the linked worktree reachable. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.create_new_thread(&worktree_workspace, window, cx); + }); + cx.run_until_parked(); + // Switch back to the main workspace. multi_workspace.update_in(cx, |mw, window, cx| { let main_ws = mw.workspaces().next().unwrap().clone(); @@ -6656,7 +6696,7 @@ async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestA "linked worktree workspace should be reachable, but reachable are: {reachable:?}" ); - // Find the DraftThread entry for the linked worktree and dismiss it. + // Find the DraftThread entry whose workspace is the linked worktree. let new_thread_ix = sidebar.read_with(cx, |sidebar, _| { sidebar .contents @@ -6666,9 +6706,9 @@ async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestA matches!( entry, ListEntry::DraftThread { - workspace: Some(_), + workspace: Some(ws), .. - } + } if ws.entity_id() == worktree_ws_id ) }) .expect("expected a DraftThread entry for the linked worktree") @@ -6687,8 +6727,25 @@ async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestA assert_eq!( multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()), - 1, - "linked worktree workspace should be removed after dismissing DraftThread entry" + 2, + "dismissing a draft no longer removes the linked worktree workspace" + ); + + let has_draft_for_worktree = sidebar.read_with(cx, |sidebar, _| { + sidebar.contents.entries.iter().any(|entry| { + matches!( + entry, + ListEntry::DraftThread { + draft_id: Some(_), + workspace: Some(ws), + .. + } if ws.entity_id() == worktree_ws_id + ) + }) + }); + assert!( + !has_draft_for_worktree, + "DraftThread entry for the linked worktree should be removed after dismiss" ); } @@ -7226,6 +7283,372 @@ async fn test_linked_worktree_workspace_reachable_after_adding_unrelated_project ); } +#[gpui::test] +async fn test_startup_failed_restoration_shows_draft(cx: &mut TestAppContext) { + // Rule 4: When the app starts and the AgentPanel fails to restore its + // last thread (no metadata), a draft should appear in the sidebar. + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + // In tests, AgentPanel::test_new doesn't call `load`, so no + // fallback draft is created. The empty group shows a placeholder. + // Simulate the startup fallback by creating a draft explicitly. + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.create_new_thread(&workspace, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " [~ Draft] *"] + ); + + sidebar.read_with(cx, |sidebar, _| { + assert_active_draft(sidebar, &workspace, "should show active draft"); + }); +} + +#[gpui::test] +async fn test_startup_successful_restoration_no_spurious_draft(cx: &mut TestAppContext) { + // Rule 5: When the app starts and the AgentPanel successfully loads + // a thread, no spurious draft should appear. + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + // Create and send a message to make a real thread. + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + let session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id, &project, cx).await; + cx.run_until_parked(); + + // Should show the thread, NOT a spurious draft. + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries, vec!["v [my-project]", " Hello *"]); + + // active_entry should be Thread, not Draft. + sidebar.read_with(cx, |sidebar, _| { + assert_active_thread(sidebar, &session_id, "should be on the thread, not a draft"); + }); +} + +#[gpui::test] +async fn test_delete_last_draft_in_empty_group_shows_placeholder(cx: &mut TestAppContext) { + // Rule 8: Deleting the last draft in a threadless group should + // leave a placeholder draft entry (not an empty group). + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + // Create two drafts explicitly (test_new doesn't call load). + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.create_new_thread(&workspace, window, cx); + }); + cx.run_until_parked(); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.create_new_thread(&workspace, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " [~ Draft] *", " [~ Draft]"] + ); + + // Delete the active (first) draft. The second should become active. + let active_draft_id = sidebar.read_with(cx, |_sidebar, cx| { + workspace + .read(cx) + .panel::(cx) + .unwrap() + .read(cx) + .active_draft_id() + .unwrap() + }); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.remove_draft(active_draft_id, &workspace, window, cx); + }); + cx.run_until_parked(); + + // Should still have 1 draft (the remaining one), now active. + let entries = visible_entries_as_strings(&sidebar, cx); + let draft_count = entries.iter().filter(|e| e.contains("Draft")).count(); + assert_eq!(draft_count, 1, "one draft should remain after deleting one"); + + // Delete the last remaining draft. + let last_draft_id = sidebar.read_with(cx, |_sidebar, cx| { + workspace + .read(cx) + .panel::(cx) + .unwrap() + .read(cx) + .active_draft_id() + .unwrap() + }); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.remove_draft(last_draft_id, &workspace, window, cx); + }); + cx.run_until_parked(); + + // The group has no threads and no tracked drafts, so a + // placeholder draft should appear. + let entries = visible_entries_as_strings(&sidebar, cx); + let draft_count = entries.iter().filter(|e| e.contains("Draft")).count(); + assert_eq!( + draft_count, 1, + "placeholder draft should appear after deleting all tracked drafts" + ); +} + +#[gpui::test] +async fn test_project_header_click_restores_last_viewed(cx: &mut TestAppContext) { + // Rule 9: Clicking a project header should restore whatever the + // user was last looking at in that group, not create new drafts + // or jump to the first entry. + let project_a = init_test_project_with_agent_panel("/project-a", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + // Create two threads in project-a. + let conn1 = StubAgentConnection::new(); + conn1.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel_a, conn1, cx); + send_message(&panel_a, cx); + let thread_a1 = active_session_id(&panel_a, cx); + save_test_thread_metadata(&thread_a1, &project_a, cx).await; + + let conn2 = StubAgentConnection::new(); + conn2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel_a, conn2, cx); + send_message(&panel_a, cx); + let thread_a2 = active_session_id(&panel_a, cx); + save_test_thread_metadata(&thread_a2, &project_a, cx).await; + cx.run_until_parked(); + + // The user is now looking at thread_a2. + sidebar.read_with(cx, |sidebar, _| { + assert_active_thread(sidebar, &thread_a2, "should be on thread_a2"); + }); + + // Add project-b and switch to it. + let fs = cx.update(|_window, cx| ::global(cx)); + fs.as_fake() + .insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + let project_b = + project::Project::test(fs.clone() as Arc, ["/project-b".as_ref()], cx).await; + let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b.clone(), window, cx) + }); + let _panel_b = add_agent_panel(&workspace_b, cx); + cx.run_until_parked(); + + // Now switch BACK to project-a by activating its workspace. + let workspace_a = multi_workspace.read_with(cx, |mw, cx| { + mw.workspaces() + .find(|ws| { + ws.read(cx) + .project() + .read(cx) + .visible_worktrees(cx) + .any(|wt| { + wt.read(cx) + .abs_path() + .to_string_lossy() + .contains("project-a") + }) + }) + .unwrap() + .clone() + }); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate(workspace_a.clone(), window, cx); + }); + cx.run_until_parked(); + + // The panel should still show thread_a2 (the last thing the user + // was viewing in project-a), not a draft or thread_a1. + sidebar.read_with(cx, |sidebar, _| { + assert_active_thread( + sidebar, + &thread_a2, + "switching back to project-a should restore thread_a2", + ); + }); + + // No spurious draft entries should have been created in + // project-a's group (project-b may have a placeholder). + let entries = visible_entries_as_strings(&sidebar, cx); + // Find project-a's section and check it has no drafts. + let project_a_start = entries + .iter() + .position(|e| e.contains("project-a")) + .unwrap(); + let project_a_end = entries[project_a_start + 1..] + .iter() + .position(|e| e.starts_with("v ")) + .map(|i| i + project_a_start + 1) + .unwrap_or(entries.len()); + let project_a_drafts = entries[project_a_start..project_a_end] + .iter() + .filter(|e| e.contains("Draft")) + .count(); + assert_eq!( + project_a_drafts, 0, + "switching back to project-a should not create drafts in its group" + ); +} + +#[gpui::test] +async fn test_plus_button_always_creates_new_draft(cx: &mut TestAppContext) { + // Rule 3: Clicking the + button on a group should always create + // a new draft, even starting from a placeholder (no tracked drafts). + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + // Start: panel has no tracked drafts, sidebar shows a placeholder. + let entries = visible_entries_as_strings(&sidebar, cx); + let draft_count = entries.iter().filter(|e| e.contains("Draft")).count(); + assert_eq!(draft_count, 1, "should start with 1 placeholder"); + + // Simulate what the + button handler does: create exactly one + // new draft per click. + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let simulate_plus_button = + |sidebar: &mut Sidebar, window: &mut Window, cx: &mut Context| { + sidebar.create_new_thread(&workspace, window, cx); + }; + + // First + click: placeholder -> 1 tracked draft. + sidebar.update_in(cx, |sidebar, window, cx| { + simulate_plus_button(sidebar, window, cx); + }); + cx.run_until_parked(); + + let entries = visible_entries_as_strings(&sidebar, cx); + let draft_count = entries.iter().filter(|e| e.contains("Draft")).count(); + assert_eq!( + draft_count, 1, + "first + click on placeholder should produce 1 tracked draft" + ); + + // Second + click: 1 -> 2 drafts. + sidebar.update_in(cx, |sidebar, window, cx| { + simulate_plus_button(sidebar, window, cx); + }); + cx.run_until_parked(); + + let entries = visible_entries_as_strings(&sidebar, cx); + let draft_count = entries.iter().filter(|e| e.contains("Draft")).count(); + assert_eq!(draft_count, 2, "second + click should add 1 more draft"); + + // Third + click: 2 -> 3 drafts. + sidebar.update_in(cx, |sidebar, window, cx| { + simulate_plus_button(sidebar, window, cx); + }); + cx.run_until_parked(); + + let entries = visible_entries_as_strings(&sidebar, cx); + let draft_count = entries.iter().filter(|e| e.contains("Draft")).count(); + assert_eq!(draft_count, 3, "third + click should add 1 more draft"); + + // The most recently created draft should be active (first in list). + assert_eq!(entries[1], " [~ Draft] *"); +} + +#[gpui::test] +async fn test_activating_workspace_with_draft_does_not_create_extras(cx: &mut TestAppContext) { + // When a workspace has a draft (from the panel's load fallback) + // and the user activates it (e.g. by clicking the placeholder or + // the project header), no extra drafts should be created. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ ".git": {}, "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = + project::Project::test(fs.clone() as Arc, ["/project-a".as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let _panel_a = add_agent_panel(&workspace_a, cx); + cx.run_until_parked(); + + // Add project-b with its own workspace and agent panel. + let project_b = + project::Project::test(fs.clone() as Arc, ["/project-b".as_ref()], cx).await; + let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b.clone(), window, cx) + }); + let _panel_b = add_agent_panel(&workspace_b, cx); + cx.run_until_parked(); + + // Explicitly create a draft on workspace_b so the sidebar tracks one. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.create_new_thread(&workspace_b, window, cx); + }); + cx.run_until_parked(); + + // Count project-b's drafts. + let count_b_drafts = |cx: &mut gpui::VisualTestContext| { + let entries = visible_entries_as_strings(&sidebar, cx); + entries + .iter() + .skip_while(|e| !e.contains("project-b")) + .take_while(|e| !e.starts_with("v ") || e.contains("project-b")) + .filter(|e| e.contains("Draft")) + .count() + }; + let drafts_before = count_b_drafts(cx); + + // Switch away from project-b, then back. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate(workspace_a.clone(), window, cx); + }); + cx.run_until_parked(); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate(workspace_b.clone(), window, cx); + }); + cx.run_until_parked(); + + let drafts_after = count_b_drafts(cx); + assert_eq!( + drafts_before, drafts_after, + "activating workspace should not create extra drafts" + ); + + // The draft should be highlighted as active after switching back. + sidebar.read_with(cx, |sidebar, _| { + assert_active_draft( + sidebar, + &workspace_b, + "draft should be active after switching back to its workspace", + ); + }); +} + mod property_test { use super::*; use gpui::proptest::prelude::*; @@ -7462,8 +7885,9 @@ mod property_test { let panel = workspace.read_with(cx, |workspace, cx| workspace.panel::(cx)); if let Some(panel) = panel { - let connection = StubAgentConnection::new(); - open_thread_with_connection(&panel, connection, cx); + panel.update_in(cx, |panel, window, cx| { + panel.new_thread(&NewThread, window, cx); + }); cx.run_until_parked(); } workspace.update_in(cx, |workspace, window, cx| { @@ -7880,11 +8304,29 @@ mod property_test { let active_workspace = multi_workspace.read(cx).workspace(); - // 1. active_entry must always be Some after rebuild_contents. - let entry = sidebar - .active_entry - .as_ref() - .ok_or_else(|| anyhow::anyhow!("active_entry must always be Some"))?; + // 1. active_entry should be Some when the panel has content. + // It may be None when the panel is uninitialized (no drafts, + // no threads), which is fine. + // It may also temporarily point at a different workspace + // when the workspace just changed and the new panel has no + // content yet. + let panel = active_workspace.read(cx).panel::(cx).unwrap(); + let panel_has_content = panel.read(cx).active_draft_id().is_some() + || panel.read(cx).active_conversation_view().is_some(); + + let Some(entry) = sidebar.active_entry.as_ref() else { + if panel_has_content { + anyhow::bail!("active_entry is None but panel has content (draft or thread)"); + } + return Ok(()); + }; + + // If the entry workspace doesn't match the active workspace + // and the panel has no content, this is a transient state that + // will resolve when the panel gets content. + if entry.workspace().entity_id() != active_workspace.entity_id() && !panel_has_content { + return Ok(()); + } // 2. The entry's workspace must agree with the multi-workspace's // active workspace. @@ -7896,11 +8338,10 @@ mod property_test { ); // 3. The entry must match the agent panel's current state. - let panel = active_workspace.read(cx).panel::(cx).unwrap(); - if panel.read(cx).active_thread_is_draft(cx) { + if panel.read(cx).active_draft_id().is_some() { anyhow::ensure!( - matches!(entry, ActiveEntry::Draft(_)), - "panel shows a draft but active_entry is {:?}", + matches!(entry, ActiveEntry::Draft { .. }), + "panel shows a tracked draft but active_entry is {:?}", entry, ); } else if let Some(session_id) = panel From 93e36aa55ef9b43efc59ebe7a3f036149f88db47 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 10 Apr 2026 18:30:10 -0600 Subject: [PATCH 7/7] Always open agent panel when selecting a thread or draft (#53664) When clicking a draft placeholder ("New Agent Thread" tab) or confirming one via keyboard, the agent panel now always opens. Previously it only focused the panel if it was already visible. Regular threads and drafts with IDs already opened the panel through `activate_draft` and `load_agent_thread_in_workspace`. The project header click continues to activate the workspace without forcing the panel open. cc @danilo-leal Release Notes: - N/A --- crates/sidebar/src/sidebar.rs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index f69e5206a6e236bd602e5d1922d33b36afb6b520..8864d9e7faa245de5ded1e38f2567d8ba2008d76 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -1718,11 +1718,6 @@ impl Sidebar { // shown, so the user returns to whatever // thread/draft they were looking at. this.activate_workspace(&workspace, window, cx); - if AgentPanel::is_visible(&workspace, cx) { - workspace.update(cx, |workspace, cx| { - workspace.focus_panel::(window, cx); - }); - } } else { this.open_workspace_for_group(&key, window, cx); } @@ -2181,6 +2176,9 @@ impl Sidebar { } } else if let Some(workspace) = workspace { self.activate_workspace(&workspace, window, cx); + workspace.update(cx, |ws, cx| { + ws.focus_panel::(window, cx); + }); } else { self.open_workspace_for_group(&key, window, cx); } @@ -4152,11 +4150,9 @@ impl Sidebar { // Placeholder with an open workspace — just // activate it. The panel remembers its last view. this.activate_workspace(workspace, window, cx); - if AgentPanel::is_visible(workspace, cx) { - workspace.update(cx, |ws, cx| { - ws.focus_panel::(window, cx); - }); - } + workspace.update(cx, |ws, cx| { + ws.focus_panel::(window, cx); + }); } else { // No workspace at all — just open one. The // panel's load fallback will create a draft.