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/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 0becd8efb4ecc1e0edb98255608f6ad463641979..34f89c2b1e78b9f154d04edb0204be418fc7edd6 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 { @@ -3482,8 +3596,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); } } @@ -4770,8 +4884,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/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..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, }; @@ -1259,6 +1261,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, @@ -4331,17 +4389,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() } @@ -8088,6 +8156,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 +8319,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) @@ -8350,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(); @@ -8558,7 +8842,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() @@ -8569,13 +8852,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 { @@ -8760,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)) 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/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, diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 4d88ddeffdd6625768dd0207176c0984e9833a29..8864d9e7faa245de5ded1e38f2567d8ba2008d76 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,17 +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); - }); - } - if AgentPanel::is_visible(&workspace, cx) { - workspace.update(cx, |workspace, cx| { - workspace.focus_panel::(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); } else { this.open_workspace_for_group(&key, window, cx); } @@ -2165,16 +2161,24 @@ 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); + workspace.update(cx, |ws, cx| { + ws.focus_panel::(window, cx); + }); } else { self.open_workspace_for_group(&key, window, cx); } @@ -2352,10 +2356,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 +2383,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 +2817,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 +2883,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 +2948,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 +2965,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 +2980,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 +3124,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 +3674,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 +3699,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); }); } - workspace.focus_panel::(window, cx); }); + + 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); + }); + } + }); + 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 +4094,120 @@ 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); + 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 +4514,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 +4535,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 +4544,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 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,