Merge remote-tracking branch 'origin/main' into fix-worktree-naming-regression

Richard Feldman created

Change summary

crates/acp_thread/src/acp_thread.rs                  |  20 
crates/agent/src/thread.rs                           |  25 
crates/agent_ui/src/agent_panel.rs                   | 178 ++
crates/agent_ui/src/agent_ui.rs                      |   4 
crates/agent_ui/src/conversation_view.rs             | 103 +
crates/agent_ui/src/conversation_view/thread_view.rs | 298 +++++
crates/agent_ui/src/thread_import.rs                 |   4 
crates/agent_ui/src/thread_worktree_archive.rs       |  21 
crates/agent_ui/src/threads_archive_view.rs          |   4 
crates/recent_projects/src/remote_servers.rs         |  15 
crates/sidebar/src/sidebar.rs                        | 708 ++++++++-----
crates/sidebar/src/sidebar_tests.rs                  | 673 +++++++++++--
crates/workspace/src/persistence.rs                  |  54 
13 files changed, 1,574 insertions(+), 533 deletions(-)

Detailed changes

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);

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<Self>,
     ) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>> {
-        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()

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::<AgentPanel>(cx) {
                         workspace.focus_panel::<AgentPanel>(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::<DraftIdCounter>();
+        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<ActiveView>,
     background_threads: HashMap<acp::SessionId, Entity<ConversationView>>,
+    draft_threads: HashMap<DraftId, Entity<ConversationView>>,
     new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
     start_thread_in_menu_handle: PopoverMenuHandle<ThreadWorktreePicker>,
     thread_branch_menu_handle: PopoverMenuHandle<ThreadBranchPicker>,
@@ -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>) {
-        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<Self>) -> 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<Self>,
+    ) {
+        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<DraftId> {
+        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<DraftId> {
+        let mut ids: Vec<DraftId> = 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<String> {
+        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<Self>) {
+        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<Workspace>,
         project: Entity<Project>,
         agent: Agent,
-        focus: bool,
         window: &mut Window,
         cx: &mut Context<Self>,
-    ) {
+    ) -> Entity<ConversationView> {
         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,
         );
     }
 

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;

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<SharedString>,
@@ -121,12 +149,57 @@ pub(crate) enum ThreadError {
 
 impl From<anyhow::Error> for ThreadError {
     fn from(error: anyhow::Error) -> Self {
-        if error.is::<language_model::PaymentRequiredError>() {
+        if error.is::<MaxOutputTokensError>() {
+            Self::MaxOutputTokens
+        } else if error.is::<NoModelConfiguredError>() {
+            Self::NoModelSelected
+        } else if error.is::<language_model::PaymentRequiredError>() {
             Self::PaymentRequired
         } else if let Some(acp_error) = error.downcast_ref::<acp::Error>()
             && acp_error.code == acp::ErrorCode::AuthRequired
         {
             Self::AuthenticationRequired(acp_error.message.clone().into())
+        } else if let Some(lm_error) = error.downcast_ref::<LanguageModelCompletionError>() {
+            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()
+            );
         });
     }
 

crates/agent_ui/src/conversation_view/thread_view.rs πŸ”—

@@ -330,6 +330,7 @@ pub struct ThreadView {
     pub hovered_recent_history_item: Option<usize>,
     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<Entity<ThreadHistory>>,
     pub _history_subscription: Option<Subscription>,
@@ -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<Self>) -> 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<Self>,
+    ) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> Option<Callout> {
+        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<Self>) -> 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<Self>) -> 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))

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),

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 {

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({

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::<MultiWorkspace>()
+                    }?;
 
                     let items = open_remote_project_with_existing_connection(
                         connection, project, paths, app_state, window, None, cx,

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<Workspace>,
     },
-    Draft(Entity<Workspace>),
+    Draft {
+        id: DraftId,
+        workspace: Entity<Workspace>,
+    },
 }
 
 impl ActiveEntry {
     fn workspace(&self) -> &Entity<Workspace> {
         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<DraftId>,
         key: project::ProjectGroupKey,
         workspace: Option<Entity<Workspace>>,
         worktrees: Vec<WorktreeInfo>,
@@ -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<Workspace>> {
-        self.active_entry.as_ref().map(|entry| entry.workspace())
-    }
-
     fn is_active_workspace(&self, workspace: &Entity<Workspace>, 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::<AgentPanel>() {
-                        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<SharedString> {
-        let mw = self.multi_workspace.upgrade()?;
-        let workspace = mw.read(cx).workspace();
-        let panel = workspace.read(cx).panel::<AgentPanel>(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::<Vec<_>>().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<Self>,
+    ) {
+        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::<AgentPanel>(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<Workspace>)> = 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::<AgentPanel>(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::<AgentPanel>(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::<AgentPanel>(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::<AgentPanel>(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::<AgentPanel>(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<Workspace>,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        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::<AgentPanel>(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::<AgentPanel>(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::<AgentPanel>(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<Workspace>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        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::<AgentPanel>(cx) {
+                panel.update(cx, |panel, cx| {
+                    panel.activate_draft(draft_id, true, window, cx);
+                });
+            }
+            ws.focus_panel::<AgentPanel>(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<Workspace>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        workspace.update(cx, |ws, cx| {
+            if let Some(panel) = ws.panel::<AgentPanel>(cx) {
+                panel.update(cx, |panel, _cx| {
+                    panel.remove_draft(draft_id);
                 });
             }
-            workspace.focus_panel::<AgentPanel>(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::<AgentPanel>(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<Workspace>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        workspace.update(cx, |ws, cx| {
+            if let Some(panel) = ws.panel::<AgentPanel>(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<SharedString> {
+        let cleaned = Self::clean_mention_links(raw);
+        let mut text: String = cleaned.split_whitespace().collect::<Vec<_>>().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<Workspace>,
+        cx: &App,
+    ) -> Option<SharedString> {
+        let panel = workspace.read(cx).panel::<AgentPanel>(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<ProjectGroupKey> {

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<Workspace>, 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::<AgentPanel>(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::<AgentPanel>(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| <dyn fs::Fs>::global(cx));
+    fs.as_fake()
+        .insert_tree("/project-b", serde_json::json!({ "src": {} }))
+        .await;
+    let project_b =
+        project::Project::test(fs.clone() as Arc<dyn Fs>, ["/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>| {
+            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| <dyn fs::Fs>::set_global(fs.clone(), cx));
+
+    let project_a =
+        project::Project::test(fs.clone() as Arc<dyn Fs>, ["/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<dyn Fs>, ["/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::<AgentPanel>(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::<AgentPanel>(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::<AgentPanel>(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

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<DateTime<Utc>>,
-    ) -> 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,