Simplify draft threads (#53940)

Mikayla Maki and Max Brunsfeld created

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A

---------

Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>

Change summary

crates/acp_thread/src/acp_thread.rs                  |  12 
crates/agent/src/agent.rs                            |  10 
crates/agent_ui/src/agent_connection_store.rs        |   2 
crates/agent_ui/src/agent_diff.rs                    |   3 
crates/agent_ui/src/agent_panel.rs                   | 594 ++++++++++---
crates/agent_ui/src/agent_ui.rs                      |  11 
crates/agent_ui/src/conversation_view.rs             |  18 
crates/agent_ui/src/conversation_view/thread_view.rs |   4 
crates/agent_ui/src/test_support.rs                  |  25 
crates/agent_ui/src/thread_metadata_store.rs         |  44 
crates/sidebar/src/sidebar.rs                        | 277 +-----
crates/sidebar/src/sidebar_tests.rs                  | 566 +-----------
12 files changed, 603 insertions(+), 963 deletions(-)

Detailed changes

crates/acp_thread/src/acp_thread.rs 🔗

@@ -1099,6 +1099,7 @@ impl From<&AcpThread> for ActionLogTelemetry {
 
 #[derive(Debug)]
 pub enum AcpThreadEvent {
+    PromptUpdated,
     NewEntry,
     TitleUpdated,
     TokenUsageUpdated,
@@ -1266,11 +1267,20 @@ impl AcpThread {
         &self.available_commands
     }
 
+    pub fn is_draft_thread(&self) -> bool {
+        self.entries().is_empty()
+    }
+
     pub fn draft_prompt(&self) -> Option<&[acp::ContentBlock]> {
         self.draft_prompt.as_deref()
     }
 
-    pub fn set_draft_prompt(&mut self, prompt: Option<Vec<acp::ContentBlock>>) {
+    pub fn set_draft_prompt(
+        &mut self,
+        prompt: Option<Vec<acp::ContentBlock>>,
+        cx: &mut Context<Self>,
+    ) {
+        cx.emit(AcpThreadEvent::PromptUpdated);
         self.draft_prompt = prompt;
     }
 

crates/agent/src/agent.rs 🔗

@@ -349,7 +349,7 @@ impl NativeAgent {
                 prompt_capabilities_rx,
                 cx,
             );
-            acp_thread.set_draft_prompt(draft_prompt);
+            acp_thread.set_draft_prompt(draft_prompt, cx);
             acp_thread.set_ui_scroll_position(scroll_position);
             acp_thread.update_token_usage(token_usage, cx);
             acp_thread
@@ -2851,8 +2851,8 @@ mod internal_tests {
             acp::ContentBlock::ResourceLink(acp::ResourceLink::new("b.md", uri.to_string())),
             acp::ContentBlock::Text(acp::TextContent::new(" please")),
         ];
-        acp_thread.update(cx, |thread, _cx| {
-            thread.set_draft_prompt(Some(draft_blocks.clone()));
+        acp_thread.update(cx, |thread, cx| {
+            thread.set_draft_prompt(Some(draft_blocks.clone()), cx);
         });
         thread.update(cx, |thread, _cx| {
             thread.set_ui_scroll_position(Some(gpui::ListOffset {
@@ -2980,8 +2980,8 @@ mod internal_tests {
         let draft_blocks = vec![acp::ContentBlock::Text(acp::TextContent::new(
             "unsaved draft",
         ))];
-        acp_thread.update(cx, |thread, _cx| {
-            thread.set_draft_prompt(Some(draft_blocks.clone()));
+        acp_thread.update(cx, |thread, cx| {
+            thread.set_draft_prompt(Some(draft_blocks.clone()), cx);
         });
 
         // Close the session immediately — no run_until_parked in between.

crates/agent_ui/src/agent_connection_store.rs 🔗

@@ -221,6 +221,8 @@ impl AgentConnectionStore {
         self.entries.retain(|key, _| match key {
             Agent::NativeAgent => true,
             Agent::Custom { id } => store.external_agents.contains_key(id),
+            #[cfg(any(test, feature = "test-support"))]
+            Agent::Stub => true,
         });
         cx.notify();
     }

crates/agent_ui/src/agent_diff.rs 🔗

@@ -1418,7 +1418,8 @@ impl AgentDiff {
             | AcpThreadEvent::Retry(_)
             | AcpThreadEvent::ModeUpdated(_)
             | AcpThreadEvent::ConfigOptionsUpdated(_)
-            | AcpThreadEvent::WorkingDirectoriesUpdated => {}
+            | AcpThreadEvent::WorkingDirectoriesUpdated
+            | AcpThreadEvent::PromptUpdated => {}
         }
     }
 

crates/agent_ui/src/agent_panel.rs 🔗

@@ -8,7 +8,7 @@ use std::{
     time::Duration,
 };
 
-use acp_thread::{AcpThread, MentionUri, ThreadStatus};
+use acp_thread::{AcpThread, AcpThreadEvent, MentionUri, ThreadStatus};
 use agent::{ContextServerRegistry, SharedThread, ThreadStore};
 use agent_client_protocol as acp;
 use agent_servers::AgentServer;
@@ -30,7 +30,7 @@ use zed_actions::{
 };
 
 use crate::DEFAULT_THREAD_TITLE;
-use crate::thread_metadata_store::{ThreadId, ThreadMetadata, ThreadMetadataStore};
+use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore};
 use crate::{
     AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, CreateWorktree,
     Follow, InlineAssistant, LoadThreadFromClipboard, NewThread, NewWorktreeBranchTarget,
@@ -69,7 +69,7 @@ use language::LanguageRegistry;
 use language_model::LanguageModelRegistry;
 use project::project_settings::ProjectSettings;
 use project::trusted_worktrees::{PathTrust, TrustedWorktrees};
-use project::{Project, ProjectPath, Worktree, WorktreePaths, linked_worktree_short_name};
+use project::{Project, ProjectPath, Worktree, linked_worktree_short_name};
 use prompt_store::{PromptStore, UserPromptId};
 use release_channel::ReleaseChannel;
 use remote::RemoteConnectionOptions;
@@ -170,11 +170,12 @@ struct SerializedAgentPanel {
     selected_agent: Option<Agent>,
     #[serde(default)]
     last_active_thread: Option<SerializedActiveThread>,
+    draft_thread_prompt: Option<Vec<acp::ContentBlock>>,
 }
 
 #[derive(Serialize, Deserialize, Debug)]
 struct SerializedActiveThread {
-    session_id: String,
+    session_id: Option<String>,
     agent_type: Agent,
     title: Option<String>,
     work_dirs: Option<SerializedPathList>,
@@ -743,6 +744,7 @@ pub struct AgentPanel {
     focus_handle: FocusHandle,
     base_view: BaseView,
     overlay_view: Option<OverlayView>,
+    draft_thread: Option<Entity<ConversationView>>,
     retained_threads: HashMap<ThreadId, Entity<ConversationView>>,
     new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
     start_thread_in_menu_handle: PopoverMenuHandle<ThreadWorktreePicker>,
@@ -765,6 +767,7 @@ pub struct AgentPanel {
     _worktree_creation_task: Option<Task<()>>,
     show_trust_workspace_message: bool,
     _base_view_observation: Option<Subscription>,
+    _draft_editor_observation: Option<Subscription>,
 }
 
 impl AgentPanel {
@@ -775,12 +778,14 @@ impl AgentPanel {
 
         let selected_agent = self.selected_agent.clone();
 
+        let is_draft_active = self.active_thread_is_draft(cx);
         let last_active_thread = self.active_agent_thread(cx).map(|thread| {
             let thread = thread.read(cx);
+
             let title = thread.title();
             let work_dirs = thread.work_dirs().cloned();
             SerializedActiveThread {
-                session_id: thread.session_id().0.to_string(),
+                session_id: (!is_draft_active).then(|| thread.session_id().0.to_string()),
                 agent_type: self.selected_agent.clone(),
                 title: title.map(|t| t.to_string()),
                 work_dirs: work_dirs.map(|dirs| dirs.serialize()),
@@ -788,12 +793,25 @@ impl AgentPanel {
         });
 
         let kvp = KeyValueStore::global(cx);
+        let draft_thread_prompt = self.draft_thread.as_ref().and_then(|conversation| {
+            Some(
+                conversation
+                    .read(cx)
+                    .root_thread(cx)?
+                    .read(cx)
+                    .thread
+                    .read(cx)
+                    .draft_prompt()?
+                    .to_vec(),
+            )
+        });
         self.pending_serialization = Some(cx.background_spawn(async move {
             save_serialized_panel(
                 workspace_id,
                 SerializedAgentPanel {
                     selected_agent: Some(selected_agent),
                     last_active_thread,
+                    draft_thread_prompt,
                 },
                 kvp,
             )
@@ -833,28 +851,38 @@ impl AgentPanel {
                 })
                 .await;
 
+            let was_draft_active = serialized_panel
+                .as_ref()
+                .and_then(|p| p.last_active_thread.as_ref())
+                .is_some_and(|t| t.session_id.is_none());
+
             let last_active_thread = if let Some(thread_info) = serialized_panel
                 .as_ref()
                 .and_then(|p| p.last_active_thread.as_ref())
             {
-                let session_id = acp::SessionId::new(thread_info.session_id.clone());
-                let is_restorable = cx
-                    .update(|_window, cx| {
-                        let store = ThreadMetadataStore::global(cx);
-                        store
-                            .read(cx)
-                            .entry_by_session(&session_id)
-                            .is_some_and(|entry| !entry.archived)
-                    })
-                    .unwrap_or(false);
-                if is_restorable {
-                    Some(thread_info)
-                } else {
-                    log::info!(
-                        "last active thread {} is archived or missing, skipping restoration",
-                        thread_info.session_id
-                    );
-                    None
+                match &thread_info.session_id {
+                    Some(session_id_str) => {
+                        let session_id = acp::SessionId::new(session_id_str.clone());
+                        let is_restorable = cx
+                            .update(|_window, cx| {
+                                let store = ThreadMetadataStore::global(cx);
+                                store
+                                    .read(cx)
+                                    .entry_by_session(&session_id)
+                                    .is_some_and(|entry| !entry.archived)
+                            })
+                            .unwrap_or(false);
+                        if is_restorable {
+                            Some(thread_info)
+                        } else {
+                            log::info!(
+                                "last active thread {} is archived or missing, skipping restoration",
+                                session_id_str
+                            );
+                            None
+                        }
+                    }
+                    None => None,
                 }
             } else {
                 None
@@ -886,23 +914,66 @@ impl AgentPanel {
                 });
 
                 if let Some(thread_info) = last_active_thread {
-                    let agent = thread_info.agent_type.clone();
+                    if let Some(session_id_str) = &thread_info.session_id {
+                        let agent = thread_info.agent_type.clone();
+                        let session_id: acp::SessionId = session_id_str.clone().into();
+                        panel.update(cx, |panel, cx| {
+                            panel.selected_agent = agent.clone();
+                            panel.load_agent_thread(
+                                agent,
+                                session_id,
+                                thread_info.work_dirs.as_ref().map(|dirs| PathList::deserialize(dirs)),
+                                thread_info.title.as_ref().map(|t| t.clone().into()),
+                                false,
+                                window,
+                                cx,
+                            );
+                        });
+                    }
+                }
+
+                let draft_prompt = serialized_panel
+                    .as_ref()
+                    .and_then(|p| p.draft_thread_prompt.clone());
+
+                if draft_prompt.is_some() || was_draft_active {
                     panel.update(cx, |panel, cx| {
-                        panel.selected_agent = agent.clone();
-                        panel.load_agent_thread(
+                        let agent = if panel.project.read(cx).is_via_collab() {
+                            Agent::NativeAgent
+                        } else {
+                            panel.selected_agent.clone()
+                        };
+                        let initial_content = draft_prompt.map(|blocks| {
+                            AgentInitialContent::ContentBlock {
+                                blocks,
+                                auto_submit: false,
+                            }
+                        });
+                        let thread = panel.create_agent_thread(
                             agent,
-                            thread_info.session_id.clone().into(),
-                            thread_info
-                                .work_dirs
-                                .as_ref()
-                                .map(|dirs| PathList::deserialize(dirs)),
-                            thread_info.title.as_ref().map(|t| t.clone().into()),
-                            false,
+                            None,
+                            None,
+                            None,
+                            initial_content,
                             window,
                             cx,
                         );
+                        panel.draft_thread = Some(thread.conversation_view.clone());
+                        panel.observe_draft_editor(&thread.conversation_view, cx);
+
+                        if was_draft_active && last_active_thread.is_none() {
+                            panel.set_base_view(
+                                BaseView::AgentThread {
+                                    conversation_view: thread.conversation_view,
+                                },
+                                false,
+                                window,
+                                cx,
+                            );
+                        }
                     });
                 }
+
                 panel
             })?;
 
@@ -1081,6 +1152,7 @@ impl AgentPanel {
             configuration_subscription: None,
             focus_handle: cx.focus_handle(),
             context_server_registry,
+            draft_thread: None,
             retained_threads: HashMap::default(),
             new_thread_menu_handle: PopoverMenuHandle::default(),
             start_thread_in_menu_handle: PopoverMenuHandle::default(),
@@ -1106,6 +1178,7 @@ impl AgentPanel {
                 cx,
             )),
             _base_view_observation: None,
+            _draft_editor_observation: None,
         };
 
         // Initial sync of agent servers from extensions
@@ -1210,21 +1283,81 @@ impl AgentPanel {
     }
 
     /// Clear the active view, retaining any running thread in the background.
-    pub fn clear_base_view(&mut self, cx: &mut Context<Self>) {
+    pub fn clear_base_view(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         let old_view = std::mem::replace(&mut self.base_view, BaseView::Uninitialized);
         self.retain_running_thread(old_view, cx);
         self.clear_overlay_state();
-        self._thread_view_subscription = None;
-        self._active_thread_focus_subscription = None;
-        self._base_view_observation = None;
+        self.activate_draft(false, window, cx);
         self.serialize(cx);
         cx.emit(AgentPanelEvent::ActiveViewChanged);
         cx.notify();
     }
 
     pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context<Self>) {
-        let id = self.create_thread(window, cx);
-        self.activate_retained_thread(id, true, window, cx);
+        self.activate_draft(true, window, cx);
+    }
+
+    pub fn activate_draft(&mut self, focus: bool, window: &mut Window, cx: &mut Context<Self>) {
+        let draft = self.ensure_draft(window, cx);
+        if let BaseView::AgentThread { conversation_view } = &self.base_view {
+            if conversation_view.entity_id() == draft.entity_id() {
+                if focus {
+                    self.focus_handle(cx).focus(window, cx);
+                }
+                return;
+            }
+        }
+        self.set_base_view(
+            BaseView::AgentThread {
+                conversation_view: draft,
+            },
+            focus,
+            window,
+            cx,
+        );
+    }
+
+    fn ensure_draft(
+        &mut self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Entity<ConversationView> {
+        if let Some(draft) = &self.draft_thread {
+            return draft.clone();
+        }
+        let agent = if self.project.read(cx).is_via_collab() {
+            Agent::NativeAgent
+        } else {
+            self.selected_agent.clone()
+        };
+        let thread = self.create_agent_thread(agent, None, None, None, None, window, cx);
+        self.draft_thread = Some(thread.conversation_view.clone());
+        self.observe_draft_editor(&thread.conversation_view, cx);
+        thread.conversation_view
+    }
+
+    fn observe_draft_editor(
+        &mut self,
+        conversation_view: &Entity<ConversationView>,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(acp_thread) = conversation_view.read(cx).root_acp_thread(cx) {
+            self._draft_editor_observation = Some(cx.subscribe(
+                &acp_thread,
+                |this, _, e: &AcpThreadEvent, cx| {
+                    if let AcpThreadEvent::PromptUpdated = e {
+                        this.serialize(cx);
+                    }
+                },
+            ));
+        } else {
+            let cv = conversation_view.clone();
+            self._draft_editor_observation = Some(cx.observe(&cv, |this, cv, cx| {
+                if cv.read(cx).root_acp_thread(cx).is_some() {
+                    this.observe_draft_editor(&cv, cx);
+                }
+            }));
+        }
     }
 
     pub fn create_thread(&mut self, window: &mut Window, cx: &mut Context<Self>) -> ThreadId {
@@ -1258,18 +1391,24 @@ impl AgentPanel {
         );
     }
 
-    pub fn remove_thread(&mut self, id: ThreadId, cx: &mut Context<Self>) {
+    pub fn remove_thread(&mut self, id: ThreadId, window: &mut Window, cx: &mut Context<Self>) {
         self.retained_threads.remove(&id);
         ThreadMetadataStore::global(cx).update(cx, |store, cx| {
             store.delete(id, cx);
         });
 
-        if self.active_thread_id(cx) == Some(id) && self.active_thread_is_draft(cx) {
-            self.base_view = BaseView::Uninitialized;
+        if self
+            .draft_thread
+            .as_ref()
+            .is_some_and(|d| d.read(cx).thread_id == id)
+        {
+            self.draft_thread = None;
+            self._draft_editor_observation = None;
+        }
+
+        if self.active_thread_id(cx) == Some(id) {
             self.clear_overlay_state();
-            self._thread_view_subscription = None;
-            self._active_thread_focus_subscription = None;
-            self._base_view_observation = None;
+            self.activate_draft(false, window, cx);
             self.serialize(cx);
             cx.emit(AgentPanelEvent::ActiveViewChanged);
             cx.notify();
@@ -1285,40 +1424,6 @@ impl AgentPanel {
         }
     }
 
-    pub fn draft_thread_ids(&self, cx: &App) -> Vec<ThreadId> {
-        let is_draft = |cv: &Entity<ConversationView>| -> bool {
-            let cv = cv.read(cx);
-            match cv.root_thread(cx) {
-                Some(tv) => tv.read(cx).is_draft(cx),
-                None => cv.is_new_draft(),
-            }
-        };
-
-        let mut ids: Vec<ThreadId> = self
-            .retained_threads
-            .iter()
-            .filter(|(_, cv)| is_draft(cv))
-            .map(|(id, _)| *id)
-            .collect();
-
-        if let BaseView::AgentThread { conversation_view } = &self.base_view {
-            let thread_id = conversation_view.read(cx).thread_id;
-            if is_draft(conversation_view) && !ids.contains(&thread_id) {
-                ids.push(thread_id);
-            }
-        }
-
-        if let Some(store) = ThreadMetadataStore::try_global(cx) {
-            let store = store.read(cx);
-            ids.sort_by(|a, b| {
-                let a_time = store.entry(*a).and_then(|m| m.created_at);
-                let b_time = store.entry(*b).and_then(|m| m.created_at);
-                b_time.cmp(&a_time)
-            });
-        }
-        ids
-    }
-
     pub fn editor_text(&self, id: ThreadId, cx: &App) -> Option<String> {
         let cv = self
             .retained_threads
@@ -1506,6 +1611,8 @@ impl AgentPanel {
                 .read(cx)
                 .entry(&self.selected_agent)
                 .map_or(false, |entry| entry.read(cx).history().is_some()),
+            #[cfg(any(test, feature = "test-support"))]
+            Agent::Stub => false,
         }
     }
 
@@ -2045,6 +2152,14 @@ impl AgentPanel {
             return;
         };
 
+        if self
+            .draft_thread
+            .as_ref()
+            .is_some_and(|d| d.entity_id() == conversation_view.entity_id())
+        {
+            return;
+        }
+
         let thread_id = conversation_view.read(cx).thread_id;
 
         if self.retained_threads.contains_key(&thread_id) {
@@ -2295,6 +2410,13 @@ impl AgentPanel {
                         let Some(thread_id) = this.active_thread_id(cx) else {
                             return;
                         };
+                        if this.draft_thread.as_ref().is_some_and(|d| {
+                            this.active_conversation_view()
+                                .is_some_and(|active| active.entity_id() == d.entity_id())
+                        }) {
+                            this.draft_thread = None;
+                            this._draft_editor_observation = None;
+                        }
                         this.retained_threads.remove(&thread_id);
                         cx.emit(AgentPanelEvent::MessageSentOrQueued { thread_id });
                     }
@@ -2489,37 +2611,6 @@ impl AgentPanel {
             .unwrap_or_else(ThreadId::new);
         let workspace = self.workspace.clone();
         let project = self.project.clone();
-        let mut worktree_paths = project.read(cx).worktree_paths(cx);
-        if let Some(existing) = &existing_metadata {
-            // When resuming a session (e.g. clicking a linked-worktree thread
-            // in the sidebar), the current workspace's project may not have
-            // completed its git scan yet. At that point `from_project()` would
-            // compute main_worktree_paths from the raw folder path instead of
-            // the git repo root, overwriting the thread's canonical project
-            // group association. Preserve the existing main_worktree_paths so
-            // the thread stays in the correct sidebar group.
-            if !existing.main_worktree_paths().is_empty() {
-                worktree_paths = WorktreePaths::from_path_lists(
-                    existing.main_worktree_paths().clone(),
-                    worktree_paths.folder_path_list().clone(),
-                )
-                .unwrap_or(worktree_paths);
-            }
-        }
-        let remote_connection = project.read(cx).remote_connection_options(cx);
-
-        if resume_session_id.is_none() {
-            let metadata = ThreadMetadata::new_draft(
-                thread_id,
-                agent.id(),
-                title.clone(),
-                worktree_paths,
-                remote_connection,
-            );
-            ThreadMetadataStore::global(cx).update(cx, |store, cx| {
-                store.save(metadata, cx);
-            });
-        }
 
         if self.selected_agent != agent {
             self.selected_agent = agent.clone();
@@ -2586,8 +2677,11 @@ impl AgentPanel {
             .is_some_and(|thread| !thread.read(cx).entries().is_empty())
     }
 
-    pub fn active_thread_is_draft(&self, cx: &App) -> bool {
-        self.active_conversation_view().is_some() && !self.active_thread_has_messages(cx)
+    pub fn active_thread_is_draft(&self, _cx: &App) -> bool {
+        self.draft_thread.as_ref().is_some_and(|draft| {
+            self.active_conversation_view()
+                .is_some_and(|active| active.entity_id() == draft.entity_id())
+        })
     }
 
     // TODO: The mapping from workspace root paths to git repositories needs a
@@ -3666,7 +3760,11 @@ impl Panel for AgentPanel {
         });
     }
 
-    fn set_active(&mut self, _active: bool, _window: &mut Window, _cx: &mut Context<Self>) {}
+    fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
+        if active {
+            self.ensure_thread_initialized(window, cx);
+        }
+    }
 
     fn remote_id() -> Option<proto::PanelId> {
         Some(proto::PanelId::AssistantPanel)
@@ -3707,6 +3805,17 @@ impl Panel for AgentPanel {
 }
 
 impl AgentPanel {
+    fn ensure_thread_initialized(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        if matches!(self.base_view, BaseView::Uninitialized)
+            && !matches!(
+                self.worktree_creation_status,
+                Some((_, WorktreeCreationStatus::Creating(_)))
+            )
+        {
+            self.activate_draft(false, window, cx);
+        }
+    }
+
     fn render_title_view(&self, _window: &mut Window, cx: &Context<Self>) -> AnyElement {
         let content = match self.visible_surface() {
             VisibleSurface::AgentThread(conversation_view) => {
@@ -5025,6 +5134,31 @@ impl AgentPanel {
     pub fn close_start_thread_in_menu_for_tests(&mut self, cx: &mut Context<Self>) {
         self.start_thread_in_menu_handle.hide(cx);
     }
+
+    /// Creates a draft thread using a stub server and sets it as the active view.
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn open_draft_with_server(
+        &mut self,
+        server: Rc<dyn AgentServer>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let ext_agent = Agent::Custom {
+            id: server.agent_id(),
+        };
+        let thread = self.create_agent_thread_with_server(
+            ext_agent,
+            Some(server),
+            None,
+            None,
+            None,
+            None,
+            window,
+            cx,
+        );
+        self.draft_thread = Some(thread.conversation_view.clone());
+        self.set_base_view(thread.into(), true, window, cx);
+    }
 }
 
 #[cfg(test)]
@@ -5702,6 +5836,186 @@ mod tests {
         (session_id, thread_id)
     }
 
+    #[gpui::test]
+    async fn test_draft_promotion_creates_metadata_and_new_session_on_reload(
+        cx: &mut TestAppContext,
+    ) {
+        init_test(cx);
+        cx.update(|cx| {
+            agent::ThreadStore::init_global(cx);
+            language_model::LanguageModelRegistry::test(cx);
+        });
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree("/project", json!({ "file.txt": "" })).await;
+        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
+
+        let multi_workspace =
+            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+
+        let workspace = multi_workspace
+            .read_with(cx, |mw, _cx| mw.workspace().clone())
+            .unwrap();
+
+        workspace.update(cx, |workspace, _cx| {
+            workspace.set_random_database_id();
+        });
+
+        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
+
+        let panel = workspace.update_in(cx, |workspace, window, cx| {
+            let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
+            workspace.add_panel(panel.clone(), window, cx);
+            panel
+        });
+
+        // Register a shared stub connection and use Agent::Stub so the draft
+        // (and any reloaded draft) uses it.
+        let stub_connection =
+            crate::test_support::set_stub_agent_connection(StubAgentConnection::new());
+        stub_connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
+            acp::ContentChunk::new("Response".into()),
+        )]);
+        panel.update_in(cx, |panel, window, cx| {
+            panel.selected_agent = Agent::Stub;
+            panel.activate_draft(true, window, cx);
+        });
+        cx.run_until_parked();
+
+        // Verify the thread is considered a draft.
+        panel.read_with(cx, |panel, cx| {
+            assert!(
+                panel.active_thread_is_draft(cx),
+                "thread should be a draft before any message is sent"
+            );
+            assert!(
+                panel.draft_thread.is_some(),
+                "draft_thread field should be set"
+            );
+        });
+        let draft_session_id = active_session_id(&panel, cx);
+        let thread_id = active_thread_id(&panel, cx);
+
+        // No metadata should exist yet for a draft.
+        cx.update(|_window, cx| {
+            let store = ThreadMetadataStore::global(cx).read(cx);
+            assert!(
+                store.entry(thread_id).is_none(),
+                "draft thread should not have metadata in the store"
+            );
+        });
+
+        // Set draft prompt and serialize — the draft should survive a round-trip
+        // with its prompt intact but a fresh ACP session.
+        let draft_prompt_blocks = vec![acp::ContentBlock::Text(acp::TextContent::new(
+            "Hello from draft",
+        ))];
+        panel.update(cx, |panel, cx| {
+            let thread = panel.active_agent_thread(cx).unwrap();
+            thread.update(cx, |thread, cx| {
+                thread.set_draft_prompt(Some(draft_prompt_blocks.clone()), cx);
+            });
+            panel.serialize(cx);
+        });
+        cx.run_until_parked();
+
+        let async_cx = cx.update(|window, cx| window.to_async(cx));
+        let reloaded_panel = AgentPanel::load(workspace.downgrade(), async_cx)
+            .await
+            .expect("panel load with draft should succeed");
+        cx.run_until_parked();
+
+        reloaded_panel.read_with(cx, |panel, cx| {
+            assert!(
+                panel.active_thread_is_draft(cx),
+                "reloaded panel should still show the draft as active"
+            );
+            assert!(
+                panel.draft_thread.is_some(),
+                "reloaded panel should have a draft_thread"
+            );
+        });
+
+        let reloaded_session_id = active_session_id(&reloaded_panel, cx);
+        assert_ne!(
+            reloaded_session_id, draft_session_id,
+            "reloaded draft should have a fresh ACP session ID"
+        );
+
+        let restored_text = reloaded_panel.read_with(cx, |panel, cx| {
+            let thread_id = panel.active_thread_id(cx).unwrap();
+            panel.editor_text(thread_id, cx)
+        });
+        assert_eq!(
+            restored_text.as_deref(),
+            Some("Hello from draft"),
+            "draft prompt text should be preserved across serialization"
+        );
+
+        // Send a message on the reloaded panel — this promotes the draft to a real thread.
+        let panel = reloaded_panel;
+        let draft_session_id = reloaded_session_id;
+        let thread_id = active_thread_id(&panel, cx);
+        send_message(&panel, cx);
+
+        // Verify promotion: draft_thread is cleared, metadata exists.
+        panel.read_with(cx, |panel, cx| {
+            assert!(
+                !panel.active_thread_is_draft(cx),
+                "thread should no longer be a draft after sending a message"
+            );
+            assert!(
+                panel.draft_thread.is_none(),
+                "draft_thread should be None after promotion"
+            );
+            assert_eq!(
+                panel.active_thread_id(cx),
+                Some(thread_id),
+                "same thread ID should remain active after promotion"
+            );
+        });
+
+        cx.update(|_window, cx| {
+            let store = ThreadMetadataStore::global(cx).read(cx);
+            let metadata = store
+                .entry(thread_id)
+                .expect("promoted thread should have metadata");
+            assert!(
+                metadata.session_id.is_some(),
+                "promoted thread metadata should have a real session_id"
+            );
+            assert_eq!(
+                metadata.session_id.as_ref().unwrap(),
+                &draft_session_id,
+                "metadata session_id should match the thread's ACP session"
+            );
+        });
+
+        // Serialize the panel, then reload it.
+        panel.update(cx, |panel, cx| panel.serialize(cx));
+        cx.run_until_parked();
+
+        let async_cx = cx.update(|window, cx| window.to_async(cx));
+        let loaded_panel = AgentPanel::load(workspace.downgrade(), async_cx)
+            .await
+            .expect("panel load should succeed");
+        cx.run_until_parked();
+
+        // The loaded panel should restore the real thread (not the draft).
+        loaded_panel.read_with(cx, |panel, cx| {
+            let active_id = panel.active_thread_id(cx);
+            assert_eq!(
+                active_id,
+                Some(thread_id),
+                "loaded panel should restore the promoted thread"
+            );
+            assert!(
+                !panel.active_thread_is_draft(cx),
+                "restored thread should not be a draft"
+            );
+        });
+    }
+
     async fn setup_panel(cx: &mut TestAppContext) -> (Entity<AgentPanel>, VisualTestContext) {
         init_test(cx);
         cx.update(|cx| {
@@ -6079,60 +6393,6 @@ mod tests {
         });
     }
 
-    #[gpui::test]
-    async fn test_set_active_blocked_during_worktree_creation(cx: &mut TestAppContext) {
-        init_test(cx);
-
-        let fs = FakeFs::new(cx.executor());
-        cx.update(|cx| {
-            agent::ThreadStore::init_global(cx);
-            language_model::LanguageModelRegistry::test(cx);
-            <dyn fs::Fs>::set_global(fs.clone(), cx);
-        });
-
-        fs.insert_tree(
-            "/project",
-            json!({
-                ".git": {},
-                "src": {
-                    "main.rs": "fn main() {}"
-                }
-            }),
-        )
-        .await;
-
-        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
-
-        let multi_workspace =
-            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
-
-        let workspace = multi_workspace
-            .read_with(cx, |multi_workspace, _cx| {
-                multi_workspace.workspace().clone()
-            })
-            .unwrap();
-
-        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
-
-        let panel = workspace.update_in(cx, |workspace, window, cx| {
-            let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
-            workspace.add_panel(panel.clone(), window, cx);
-            panel
-        });
-
-        cx.run_until_parked();
-
-        // set_active no longer creates threads — verify it's a no-op.
-        panel.update_in(cx, |panel, window, cx| {
-            panel.base_view = BaseView::Uninitialized;
-            Panel::set_active(panel, true, window, cx);
-            assert!(
-                matches!(panel.base_view, BaseView::Uninitialized),
-                "set_active should not create a thread"
-            );
-        });
-    }
-
     #[test]
     fn test_deserialize_agent_variants() {
         // PascalCase (legacy AgentType format, persisted in panel state)

crates/agent_ui/src/agent_ui.rs 🔗

@@ -259,6 +259,7 @@ pub struct NewNativeAgentThreadFromSummary {
 
 #[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
 #[serde(rename_all = "snake_case")]
+#[non_exhaustive]
 pub enum Agent {
     #[default]
     #[serde(alias = "NativeAgent", alias = "TextThread")]
@@ -268,6 +269,8 @@ pub enum Agent {
         #[serde(rename = "name")]
         id: AgentId,
     },
+    #[cfg(any(test, feature = "test-support"))]
+    Stub,
 }
 
 impl From<AgentId> for Agent {
@@ -285,6 +288,8 @@ impl Agent {
         match self {
             Self::NativeAgent => agent::ZED_AGENT_ID.clone(),
             Self::Custom { id } => id.clone(),
+            #[cfg(any(test, feature = "test-support"))]
+            Self::Stub => "stub".into(),
         }
     }
 
@@ -296,6 +301,8 @@ impl Agent {
         match self {
             Self::NativeAgent => "Zed Agent".into(),
             Self::Custom { id, .. } => id.0.clone(),
+            #[cfg(any(test, feature = "test-support"))]
+            Self::Stub => "Stub Agent".into(),
         }
     }
 
@@ -303,6 +310,8 @@ impl Agent {
         match self {
             Self::NativeAgent => None,
             Self::Custom { .. } => Some(IconName::Sparkle),
+            #[cfg(any(test, feature = "test-support"))]
+            Self::Stub => None,
         }
     }
 
@@ -316,6 +325,8 @@ impl Agent {
             Self::Custom { id: name } => {
                 Rc::new(agent_servers::CustomAgentServer::new(name.clone()))
             }
+            #[cfg(any(test, feature = "test-support"))]
+            Self::Stub => Rc::new(crate::test_support::StubAgentServer::default_response()),
         }
     }
 }

crates/agent_ui/src/conversation_view.rs 🔗

@@ -285,7 +285,8 @@ impl Conversation {
                     | AcpThreadEvent::AvailableCommandsUpdated(_)
                     | AcpThreadEvent::ModeUpdated(_)
                     | AcpThreadEvent::ConfigOptionsUpdated(_)
-                    | AcpThreadEvent::WorkingDirectoriesUpdated => {}
+                    | AcpThreadEvent::WorkingDirectoriesUpdated
+                    | AcpThreadEvent::PromptUpdated => {}
                 }
             }
         });
@@ -408,7 +409,8 @@ fn affects_thread_metadata(event: &AcpThreadEvent) -> bool {
         | AcpThreadEvent::AvailableCommandsUpdated(_)
         | AcpThreadEvent::ModeUpdated(_)
         | AcpThreadEvent::ConfigOptionsUpdated(_)
-        | AcpThreadEvent::SubagentSpawned(_) => false,
+        | AcpThreadEvent::SubagentSpawned(_)
+        | AcpThreadEvent::PromptUpdated => false,
     }
 }
 
@@ -435,9 +437,6 @@ pub struct ConversationView {
     notification_subscriptions: HashMap<WindowHandle<AgentNotification>, Vec<Subscription>>,
     auth_task: Option<Task<()>>,
     _subscriptions: Vec<Subscription>,
-    /// True when this conversation was created as a new draft (no resume
-    /// session). False when resuming an existing session from history.
-    is_new_draft: bool,
 }
 
 impl ConversationView {
@@ -447,10 +446,6 @@ impl ConversationView {
         })
     }
 
-    pub fn is_new_draft(&self) -> bool {
-        self.is_new_draft
-    }
-
     pub fn active_thread(&self) -> Option<&Entity<ThreadView>> {
         match &self.server_state {
             ServerState::Connected(connected) => connected.active_view(),
@@ -692,7 +687,6 @@ impl ConversationView {
         })
         .detach();
 
-        let is_new_draft = resume_session_id.is_none();
         let thread_id = thread_id.unwrap_or_else(ThreadId::new);
 
         Self {
@@ -723,7 +717,6 @@ impl ConversationView {
             auth_task: None,
             _subscriptions: subscriptions,
             focus_handle: cx.focus_handle(),
-            is_new_draft,
         }
     }
 
@@ -1631,6 +1624,9 @@ impl ConversationView {
             AcpThreadEvent::WorkingDirectoriesUpdated => {
                 cx.notify();
             }
+            AcpThreadEvent::PromptUpdated => {
+                cx.notify();
+            }
         }
         cx.notify();
     }

crates/agent_ui/src/conversation_view/thread_view.rs 🔗

@@ -492,8 +492,8 @@ impl ThreadView {
                     None
                 };
                 this.update(cx, |this, cx| {
-                    this.thread.update(cx, |thread, _cx| {
-                        thread.set_draft_prompt(draft);
+                    this.thread.update(cx, |thread, cx| {
+                        thread.set_draft_prompt(draft, cx);
                     });
                     this.schedule_save(cx);
                 })

crates/agent_ui/src/test_support.rs 🔗

@@ -6,11 +6,36 @@ use project::AgentId;
 use project::Project;
 use settings::SettingsStore;
 use std::any::Any;
+use std::cell::RefCell;
 use std::rc::Rc;
 
 use crate::AgentPanel;
 use crate::agent_panel;
 
+thread_local! {
+    static STUB_AGENT_CONNECTION: RefCell<Option<StubAgentConnection>> = const { RefCell::new(None) };
+}
+
+/// Registers a `StubAgentConnection` that will be used by `Agent::Stub`.
+///
+/// Returns the same connection so callers can hold onto it and control
+/// the stub's behavior (e.g. `connection.set_next_prompt_updates(...)`).
+pub fn set_stub_agent_connection(connection: StubAgentConnection) -> StubAgentConnection {
+    STUB_AGENT_CONNECTION.with(|cell| {
+        *cell.borrow_mut() = Some(connection.clone());
+    });
+    connection
+}
+
+/// Returns the shared `StubAgentConnection` used by `Agent::Stub`,
+/// creating a default one if none was registered.
+pub fn stub_agent_connection() -> StubAgentConnection {
+    STUB_AGENT_CONNECTION.with(|cell| {
+        let mut borrow = cell.borrow_mut();
+        borrow.get_or_insert_with(StubAgentConnection::new).clone()
+    })
+}
+
 pub struct StubAgentServer<C> {
     connection: C,
     agent_id: AgentId,

crates/agent_ui/src/thread_metadata_store.rs 🔗

@@ -275,31 +275,6 @@ pub struct ThreadMetadata {
 }
 
 impl ThreadMetadata {
-    pub fn new_draft(
-        thread_id: ThreadId,
-        agent_id: AgentId,
-        title: Option<SharedString>,
-        worktree_paths: WorktreePaths,
-        remote_connection: Option<RemoteConnectionOptions>,
-    ) -> Self {
-        let now = Utc::now();
-        Self {
-            thread_id,
-            session_id: None,
-            agent_id,
-            title,
-            updated_at: now,
-            created_at: Some(now),
-            worktree_paths: worktree_paths.clone(),
-            remote_connection,
-            archived: worktree_paths.is_empty(),
-        }
-    }
-
-    pub fn is_draft(&self) -> bool {
-        self.session_id.is_none()
-    }
-
     pub fn display_title(&self) -> SharedString {
         self.title
             .clone()
@@ -1140,7 +1115,7 @@ impl ThreadMetadataStore {
         };
 
         let thread_ref = thread.read(cx);
-        if thread_ref.entries().is_empty() {
+        if thread_ref.is_draft_thread() {
             return;
         }
 
@@ -2272,18 +2247,14 @@ mod tests {
         let session_id = thread.read_with(&vcx, |t, _| t.session_id().clone());
         let thread_id = crate::test_support::active_thread_id(&panel, &vcx);
 
-        // Initial metadata was created by the panel with session_id: None.
+        // Draft threads no longer create metadata entries.
         cx.read(|cx| {
             let store = ThreadMetadataStore::global(cx).read(cx);
-            assert_eq!(store.entry_ids().count(), 1);
-            assert!(
-                store.entry(thread_id).unwrap().session_id.is_none(),
-                "expected initial panel metadata to have no session_id"
-            );
+            assert_eq!(store.entry_ids().count(), 0);
         });
 
         // Setting a title on an empty thread should be ignored by the
-        // event handler (entries are empty), leaving session_id as None.
+        // event handler (entries are empty), so no metadata is created.
         thread.update_in(&mut vcx, |thread, _window, cx| {
             thread.set_title("Draft Thread".into(), cx).detach();
         });
@@ -2291,9 +2262,10 @@ mod tests {
 
         cx.read(|cx| {
             let store = ThreadMetadataStore::global(cx).read(cx);
-            assert!(
-                store.entry(thread_id).unwrap().session_id.is_none(),
-                "expected title updates on empty thread to be ignored by event handler"
+            assert_eq!(
+                store.entry_ids().count(),
+                0,
+                "expected title updates on empty thread to not create metadata"
             );
         });
 

crates/sidebar/src/sidebar.rs 🔗

@@ -192,7 +192,6 @@ struct ThreadEntry {
     is_live: bool,
     is_background: bool,
     is_title_generating: bool,
-    is_draft: bool,
     highlight_positions: Vec<usize>,
     worktrees: Vec<ThreadItemWorktreeInfo>,
     diff_stats: DiffStats,
@@ -377,7 +376,6 @@ pub struct Sidebar {
     recent_projects_popover_handle: PopoverMenuHandle<SidebarRecentProjects>,
     project_header_menu_ix: Option<usize>,
     _subscriptions: Vec<gpui::Subscription>,
-    _draft_observations: Vec<gpui::Subscription>,
 }
 
 impl Sidebar {
@@ -404,7 +402,6 @@ impl Sidebar {
                 MultiWorkspaceEvent::ActiveWorkspaceChanged => {
                     this.sync_active_entry_from_active_workspace(cx);
                     this.replace_archived_panel_thread(window, cx);
-                    this.observe_draft_editors(cx);
                     this.update_entries(cx);
                 }
                 MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
@@ -466,7 +463,6 @@ impl Sidebar {
             recent_projects_popover_handle: PopoverMenuHandle::default(),
             project_header_menu_ix: None,
             _subscriptions: Vec::new(),
-            _draft_observations: Vec::new(),
         }
     }
 
@@ -544,12 +540,10 @@ impl Sidebar {
                 ProjectEvent::WorktreeAdded(_)
                 | ProjectEvent::WorktreeRemoved(_)
                 | ProjectEvent::WorktreeOrderChanged => {
-                    this.observe_draft_editors(cx);
                     this.update_entries(cx);
                 }
                 ProjectEvent::WorktreePathsChanged { old_worktree_paths } => {
                     this.move_thread_paths(project, old_worktree_paths, cx);
-                    this.observe_draft_editors(cx);
                     this.update_entries(cx);
                 }
                 _ => {}
@@ -595,7 +589,6 @@ impl Sidebar {
 
         if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
             self.subscribe_to_agent_panel(&agent_panel, window, cx);
-            self.observe_draft_editors(cx);
         }
     }
 
@@ -660,7 +653,6 @@ impl Sidebar {
             |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event {
                 AgentPanelEvent::ActiveViewChanged => {
                     this.sync_active_entry_from_panel(_agent_panel, cx);
-                    this.observe_draft_editors(cx);
                     this.update_entries(cx);
                 }
                 AgentPanelEvent::ThreadFocused | AgentPanelEvent::RetainedThreadChanged => {
@@ -795,63 +787,6 @@ impl Sidebar {
         }
     }
 
-    fn observe_draft_editors(&mut self, cx: &mut Context<Self>) {
-        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
-            self._draft_observations.clear();
-            return;
-        };
-
-        // Collect conversation views up front to avoid holding a
-        // borrow on `cx` across `cx.observe` calls.
-        let conversation_views: Vec<_> = multi_workspace
-            .read(cx)
-            .workspaces()
-            .filter_map(|ws| ws.read(cx).panel::<AgentPanel>(cx))
-            .flat_map(|panel| panel.read(cx).conversation_views())
-            .collect();
-
-        let mut subscriptions = Vec::with_capacity(conversation_views.len());
-        for cv in conversation_views {
-            if let Some(thread_view) = cv.read(cx).active_thread() {
-                let editor = thread_view.read(cx).message_editor.clone();
-                subscriptions.push(cx.observe(&editor, |this, _editor, cx| {
-                    this.update_entries(cx);
-                }));
-            } else {
-                subscriptions.push(cx.observe(&cv, |this, _cv, cx| {
-                    this.observe_draft_editors(cx);
-                    this.update_entries(cx);
-                }));
-            }
-        }
-
-        self._draft_observations = subscriptions;
-    }
-
-    fn clean_mention_links(input: &str) -> String {
-        let mut result = String::with_capacity(input.len());
-        let mut remaining = input;
-
-        while let Some(start) = remaining.find("[@") {
-            result.push_str(&remaining[..start]);
-            let after_bracket = &remaining[start + 1..]; // skip '['
-            if let Some(close_bracket) = after_bracket.find("](") {
-                let mention = &after_bracket[..close_bracket]; // "@something"
-                let after_link_start = &after_bracket[close_bracket + 2..]; // after "]("
-                if let Some(close_paren) = after_link_start.find(')') {
-                    result.push_str(mention);
-                    remaining = &after_link_start[close_paren + 1..];
-                    continue;
-                }
-            }
-            // Couldn't parse full link syntax — emit the literal "[@" and move on.
-            result.push_str("[@");
-            remaining = &remaining[start + 2..];
-        }
-        result.push_str(remaining);
-        result
-    }
-
     /// Opens a new workspace for a group that has no open workspaces.
     fn open_workspace_for_group(
         &mut self,
@@ -989,6 +924,8 @@ impl Sidebar {
             let icon = match agent {
                 Agent::NativeAgent => IconName::ZedAgent,
                 Agent::Custom { .. } => IconName::Terminal,
+
+                _ => IconName::ZedAgent,
             };
             let icon_from_external_svg = agent_server_store
                 .as_ref()
@@ -1091,7 +1028,6 @@ impl Sidebar {
                         let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id);
                         let worktrees =
                             worktree_info_from_thread_paths(&row.worktree_paths, &branch_by_path);
-                        let is_draft = row.is_draft();
                         ThreadEntry {
                             metadata: row,
                             icon,
@@ -1101,7 +1037,6 @@ impl Sidebar {
                             is_live: false,
                             is_background: false,
                             is_title_generating: false,
-                            is_draft,
                             highlight_positions: Vec::new(),
                             worktrees,
                             diff_stats: DiffStats::default(),
@@ -1314,26 +1249,6 @@ impl Sidebar {
                     continue;
                 }
 
-                {
-                    // Override titles with editor text for drafts and
-                    // threads that still have the default placeholder
-                    // title (panel considers them drafts even if they
-                    // have a session_id).
-                    for thread in &mut threads {
-                        let needs_title_override =
-                            thread.is_draft || thread.metadata.title.is_none();
-                        if needs_title_override {
-                            if let ThreadEntryWorkspace::Open(workspace) = &thread.workspace {
-                                if let Some(text) =
-                                    self.read_draft_text(thread.metadata.thread_id, workspace, cx)
-                                {
-                                    thread.metadata.title = Some(text);
-                                }
-                            }
-                        }
-                    }
-                }
-
                 let total = threads.len();
 
                 let extra_batches = self.group_extra_batches(&group_key, cx);
@@ -1472,19 +1387,31 @@ impl Sidebar {
                 waiting_thread_count,
                 is_active: is_active_group,
                 has_threads,
-            } => self.render_project_header(
-                ix,
-                false,
-                key,
-                label,
-                highlight_positions,
-                *has_running_threads,
-                *waiting_thread_count,
-                *is_active_group,
-                is_selected,
-                *has_threads,
-                cx,
-            ),
+            } => {
+                let has_active_draft = is_active
+                    && self
+                        .active_workspace(cx)
+                        .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
+                        .is_some_and(|panel| {
+                            let panel = panel.read(cx);
+                            panel.active_thread_is_draft(cx)
+                                || panel.active_conversation_view().is_none()
+                        });
+                self.render_project_header(
+                    ix,
+                    false,
+                    key,
+                    label,
+                    highlight_positions,
+                    *has_running_threads,
+                    *waiting_thread_count,
+                    *is_active_group,
+                    is_selected,
+                    *has_threads,
+                    has_active_draft,
+                    cx,
+                )
+            }
             ListEntry::Thread(thread) => self.render_thread(ix, thread, is_active, is_selected, cx),
             ListEntry::ViewMore {
                 key,
@@ -1540,6 +1467,7 @@ impl Sidebar {
         is_active: bool,
         is_focused: bool,
         has_threads: bool,
+        has_active_draft: bool,
         cx: &mut Context<Self>,
     ) -> AnyElement {
         let host = key.host();
@@ -1664,7 +1592,7 @@ impl Sidebar {
             .child(gradient_overlay())
             .child(
                 h_flex()
-                    .when(!is_ellipsis_menu_open, |this| {
+                    .when(!is_ellipsis_menu_open && !has_active_draft, |this| {
                         this.visible_on_hover(&group_name)
                     })
                     .child(gradient_overlay())
@@ -1682,6 +1610,7 @@ impl Sidebar {
                             IconName::Plus,
                         )
                         .icon_size(IconSize::Small)
+                        .when(has_active_draft, |this| this.icon_color(Color::Accent))
                         .tooltip(move |_, cx| {
                             Tooltip::for_action_in(
                                 "Start New Agent Thread",
@@ -1954,6 +1883,14 @@ impl Sidebar {
         let is_focused = self.focus_handle.is_focused(window);
         let is_selected = is_focused && self.selection == Some(header_idx);
 
+        let has_active_draft = *is_active
+            && self
+                .active_workspace(cx)
+                .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
+                .is_some_and(|panel| {
+                    let panel = panel.read(cx);
+                    panel.active_thread_is_draft(cx) || panel.active_conversation_view().is_none()
+                });
         let header_element = self.render_project_header(
             header_idx,
             true,
@@ -1965,6 +1902,7 @@ impl Sidebar {
             *is_active,
             is_selected,
             *has_threads,
+            has_active_draft,
             cx,
         );
 
@@ -2374,7 +2312,6 @@ impl Sidebar {
                 ws.focus_panel::<AgentPanel>(window, cx);
             });
             self.pending_thread_activation = None;
-            self.observe_draft_editors(cx);
         } else {
             Self::load_agent_thread_in_workspace(workspace, metadata, true, window, cx);
         }
@@ -2958,9 +2895,7 @@ impl Sidebar {
                 .iter()
                 .chain(self.contents.entries[..pos].iter().rev())
                 .find_map(|entry| match entry {
-                    ListEntry::Thread(t)
-                        if !t.is_draft && t.metadata.session_id.as_ref() != Some(session_id) =>
-                    {
+                    ListEntry::Thread(t) if t.metadata.session_id.as_ref() != Some(session_id) => {
                         let (workspace_paths, project_group_key) = match &t.workspace {
                             ThreadEntryWorkspace::Open(ws) => (
                                 PathList::new(&ws.read(cx).root_paths(cx)),
@@ -3260,7 +3195,7 @@ impl Sidebar {
                             });
                         if panel_shows_archived {
                             panel.update(cx, |panel, cx| {
-                                panel.clear_base_view(cx);
+                                panel.clear_base_view(window, cx);
                             });
                         }
                     }
@@ -3300,7 +3235,7 @@ impl Sidebar {
             if let Some(workspace) = workspace {
                 if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
                     panel.update(cx, |panel, cx| {
-                        panel.clear_base_view(cx);
+                        panel.clear_base_view(window, cx);
                     });
                 }
             }
@@ -3416,13 +3351,6 @@ impl Sidebar {
             return;
         };
         match self.contents.entries.get(ix) {
-            Some(ListEntry::Thread(thread)) if thread.is_draft => {
-                let draft_id = thread.metadata.thread_id;
-                if let ThreadEntryWorkspace::Open(workspace) = &thread.workspace {
-                    let workspace = workspace.clone();
-                    self.remove_draft(draft_id, &workspace, window, cx);
-                }
-            }
             Some(ListEntry::Thread(thread)) => {
                 match thread.status {
                     AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation => {
@@ -3735,7 +3663,6 @@ impl Sidebar {
         let title: SharedString = thread.metadata.display_title();
         let metadata = thread.metadata.clone();
         let thread_workspace = thread.workspace.clone();
-        let is_draft = thread.is_draft;
 
         let is_hovered = self.hovered_thread_index == Some(ix);
         let is_selected = is_active;
@@ -3746,7 +3673,6 @@ impl Sidebar {
 
         let thread_id_for_actions = thread.metadata.thread_id;
         let session_id_for_delete = thread.metadata.session_id.clone();
-        let thread_workspace_for_dismiss = thread.workspace.clone();
         let focus_handle = self.focus_handle.clone();
 
         let id = SharedString::from(format!("thread-entry-{}", ix));
@@ -3804,36 +3730,7 @@ impl Sidebar {
                         }),
                 )
             })
-            .when(is_hovered && !is_running && is_draft, |this| {
-                this.action_slot(
-                    div()
-                        .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
-                            cx.stop_propagation();
-                        })
-                        .child(
-                            IconButton::new("close-draft", IconName::Close)
-                                .icon_size(IconSize::Small)
-                                .icon_color(Color::Muted)
-                                .tooltip(Tooltip::text("Remove Draft"))
-                                .on_click({
-                                    let thread_workspace = thread_workspace_for_dismiss.clone();
-                                    cx.listener(move |this, _, window, cx| {
-                                        if let ThreadEntryWorkspace::Open(workspace) =
-                                            &thread_workspace
-                                        {
-                                            this.remove_draft(
-                                                thread_id_for_actions,
-                                                workspace,
-                                                window,
-                                                cx,
-                                            );
-                                        }
-                                    })
-                                }),
-                        ),
-                )
-            })
-            .when(is_hovered && !is_running && !is_draft, |this| {
+            .when(is_hovered && !is_running, |this| {
                 this.action_slot(
                     IconButton::new("archive-thread", IconName::Archive)
                         .icon_size(IconSize::Small)
@@ -4018,19 +3915,11 @@ impl Sidebar {
         let draft_id = workspace.update(cx, |workspace, cx| {
             let panel = workspace.panel::<AgentPanel>(cx)?;
             let draft_id = panel.update(cx, |panel, cx| {
-                if let Some(id) = panel.draft_thread_ids(cx).first().copied() {
-                    if panel.active_thread_id(cx) != Some(id) {
-                        panel.activate_retained_thread(id, true, window, cx);
-                    }
-                    id
-                } else {
-                    let id = panel.create_thread(window, cx);
-                    panel.activate_retained_thread(id, true, window, cx);
-                    id
-                }
+                panel.activate_draft(true, window, cx);
+                panel.active_thread_id(cx)
             });
             workspace.focus_panel::<AgentPanel>(window, cx);
-            Some(draft_id)
+            draft_id
         });
 
         if let Some(draft_id) = draft_id {
@@ -4042,80 +3931,6 @@ impl Sidebar {
         }
     }
 
-    fn remove_draft(
-        &mut self,
-        draft_id: ThreadId,
-        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_thread(draft_id, cx);
-                });
-            }
-        });
-
-        let was_active = self
-            .active_entry
-            .as_ref()
-            .is_some_and(|e| e.is_active_thread(&draft_id));
-
-        if was_active {
-            let group_key = workspace.read(cx).project_group_key(cx);
-
-            // Find any remaining thread in the same group.
-            let next = self.contents.entries.iter().find_map(|entry| {
-                if let ListEntry::Thread(thread) = entry {
-                    if thread.metadata.thread_id != draft_id {
-                        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)) = next {
-                self.activate_thread(metadata, &ws, false, window, cx);
-            } else {
-                self.active_entry = None;
-            }
-        }
-
-        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 first_line = raw.lines().next().unwrap_or("");
-        let cleaned = Self::clean_mention_links(first_line);
-        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: ThreadId,
-        workspace: &Entity<Workspace>,
-        cx: &App,
-    ) -> Option<SharedString> {
-        let panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
-        let raw = panel.read(cx).editor_text(draft_id, cx)?;
-        Self::truncate_draft_label(&raw)
-    }
-
     fn selected_group_key(&self) -> Option<ProjectGroupKey> {
         let ix = self.selection?;
         match self.contents.entries.get(ix) {

crates/sidebar/src/sidebar_tests.rs 🔗

@@ -138,7 +138,6 @@ fn assert_remote_project_integration_sidebar_state(
             {
                 saw_remote_thread = true;
             }
-            ListEntry::Thread(thread) if thread.is_draft => {}
             ListEntry::Thread(thread) => {
                 let title = thread.metadata.display_title();
                 panic!(
@@ -409,14 +408,7 @@ fn visible_entries_as_strings(
                         let title = thread.metadata.display_title();
                         let worktree = format_linked_worktree_chips(&thread.worktrees);
 
-                        if thread.is_draft {
-                            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}")
-                        } else {
+                        {
                             let live = if thread.is_live { " *" } else { "" };
                             let status_str = match thread.status {
                                 AgentThreadStatus::Running => " (running)",
@@ -523,44 +515,6 @@ async fn test_restore_serialized_archive_view_does_not_panic(cx: &mut TestAppCon
     });
 }
 
-#[test]
-fn test_clean_mention_links() {
-    // Simple mention link
-    assert_eq!(
-        Sidebar::clean_mention_links("check [@Button.tsx](file:///path/to/Button.tsx)"),
-        "check @Button.tsx"
-    );
-
-    // Multiple mention links
-    assert_eq!(
-        Sidebar::clean_mention_links(
-            "look at [@foo.rs](file:///foo.rs) and [@bar.rs](file:///bar.rs)"
-        ),
-        "look at @foo.rs and @bar.rs"
-    );
-
-    // No mention links — passthrough
-    assert_eq!(
-        Sidebar::clean_mention_links("plain text with no mentions"),
-        "plain text with no mentions"
-    );
-
-    // Incomplete link syntax — preserved as-is
-    assert_eq!(
-        Sidebar::clean_mention_links("broken [@mention without closing"),
-        "broken [@mention without closing"
-    );
-
-    // Regular markdown link (no @) — not touched
-    assert_eq!(
-        Sidebar::clean_mention_links("see [docs](https://example.com)"),
-        "see [docs](https://example.com)"
-    );
-
-    // Empty input
-    assert_eq!(Sidebar::clean_mention_links(""), "");
-}
-
 #[gpui::test]
 async fn test_entities_released_on_window_close(cx: &mut TestAppContext) {
     let project = init_test_project("/my-project", cx).await;
@@ -936,7 +890,6 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                 is_live: false,
                 is_background: false,
                 is_title_generating: false,
-                is_draft: false,
                 highlight_positions: Vec::new(),
                 worktrees: Vec::new(),
                 diff_stats: DiffStats::default(),
@@ -961,7 +914,6 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                 is_live: true,
                 is_background: false,
                 is_title_generating: false,
-                is_draft: false,
                 highlight_positions: Vec::new(),
                 worktrees: Vec::new(),
                 diff_stats: DiffStats::default(),
@@ -986,7 +938,6 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                 is_live: true,
                 is_background: false,
                 is_title_generating: false,
-                is_draft: false,
                 highlight_positions: Vec::new(),
                 worktrees: Vec::new(),
                 diff_stats: DiffStats::default(),
@@ -1012,7 +963,6 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                 is_live: false,
                 is_background: false,
                 is_title_generating: false,
-                is_draft: false,
                 highlight_positions: Vec::new(),
                 worktrees: Vec::new(),
                 diff_stats: DiffStats::default(),
@@ -1038,7 +988,6 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                 is_live: true,
                 is_background: true,
                 is_title_generating: false,
-                is_draft: false,
                 highlight_positions: Vec::new(),
                 worktrees: Vec::new(),
                 diff_stats: DiffStats::default(),
@@ -2926,219 +2875,11 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex
         );
     });
 }
-#[gpui::test]
-async fn test_draft_title_updates_from_editor_text(cx: &mut TestAppContext) {
-    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 new thread (activates the draft as base view and connects).
-    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
-    let panel = workspace.read_with(cx, |ws, cx| ws.panel::<AgentPanel>(cx).unwrap());
-    let connection = StubAgentConnection::new();
-    open_thread_with_connection(&panel, connection, cx);
-    cx.run_until_parked();
-
-    // Type into the draft's message editor.
-    let thread_view = panel.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
-    let message_editor = thread_view.read_with(cx, |view, _cx| view.message_editor.clone());
-    message_editor.update_in(cx, |editor, window, cx| {
-        editor.set_text("Fix the login bug", window, cx);
-    });
-    cx.run_until_parked();
-
-    // The sidebar draft title should now reflect the editor text.
-    let draft_title = sidebar.read_with(cx, |sidebar, _cx| {
-        sidebar
-            .contents
-            .entries
-            .iter()
-            .find_map(|entry| match entry {
-                ListEntry::Thread(thread) if thread.is_draft => {
-                    Some(thread.metadata.display_title())
-                }
-                _ => None,
-            })
-            .expect("should still have a draft entry")
-    });
-    assert_eq!(
-        draft_title.as_ref(),
-        "Fix the login bug",
-        "draft title should update to match editor text"
-    );
-}
-
-#[gpui::test]
-async fn test_draft_title_updates_across_two_groups(cx: &mut TestAppContext) {
-    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);
-
-    // Add a second project group.
-    let fs = cx.update(|_, 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, ["/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();
-
-    // Open a thread in each group's panel to get Connected state.
-    let workspace_a =
-        multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
-    let panel_a = workspace_a.read_with(cx, |ws, cx| ws.panel::<AgentPanel>(cx).unwrap());
-
-    let connection_a = StubAgentConnection::new();
-    open_thread_with_connection(&panel_a, connection_a, cx);
-    cx.run_until_parked();
-
-    let connection_b = StubAgentConnection::new();
-    open_thread_with_connection(&panel_b, connection_b, cx);
-    cx.run_until_parked();
-
-    // Type into group A's draft editor.
-    let thread_view_a = panel_a.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
-    let editor_a = thread_view_a.read_with(cx, |view, _cx| view.message_editor.clone());
-    editor_a.update_in(cx, |editor, window, cx| {
-        editor.set_text("Fix the login bug", window, cx);
-    });
-    cx.run_until_parked();
-
-    // Type into group B's draft editor.
-    let thread_view_b = panel_b.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
-    let editor_b = thread_view_b.read_with(cx, |view, _cx| view.message_editor.clone());
-    editor_b.update_in(cx, |editor, window, cx| {
-        editor.set_text("Refactor the database", window, cx);
-    });
-    cx.run_until_parked();
-
-    // Both draft titles should reflect their respective editor text.
-    let draft_titles: Vec<SharedString> = sidebar.read_with(cx, |sidebar, _cx| {
-        sidebar
-            .contents
-            .entries
-            .iter()
-            .filter_map(|entry| match entry {
-                ListEntry::Thread(thread) if thread.is_draft => {
-                    Some(thread.metadata.display_title())
-                }
-                _ => None,
-            })
-            .collect()
-    });
-    assert_eq!(draft_titles.len(), 2, "should still have two drafts");
-    assert!(
-        draft_titles.contains(&SharedString::from("Fix the login bug")),
-        "group A draft should show editor text, got: {:?}",
-        draft_titles
-    );
-    assert!(
-        draft_titles.contains(&SharedString::from("Refactor the database")),
-        "group B draft should show editor text, got: {:?}",
-        draft_titles
-    );
-}
-
-#[gpui::test]
-async fn test_draft_title_survives_folder_addition(cx: &mut TestAppContext) {
-    // When a folder is added to the project, the group key changes.
-    // The draft's editor observation should still work and the title
-    // should update when the user types.
-    init_test(cx);
-    let fs = FakeFs::new(cx.executor());
-    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
-        .await;
-    fs.insert_tree("/project-b", serde_json::json!({ "lib": {} }))
-        .await;
-    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
-
-    let project = project::Project::test(fs.clone(), [Path::new("/project-a")], 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 thread with a connection (has a session_id, considered
-    // a draft by the panel until messages are sent).
-    let connection = StubAgentConnection::new();
-    open_thread_with_connection(&panel, connection, cx);
-    cx.run_until_parked();
-
-    // Type into the editor.
-    let thread_view = panel.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
-    let editor = thread_view.read_with(cx, |view, _cx| view.message_editor.clone());
-    editor.update_in(cx, |editor, window, cx| {
-        editor.set_text("Initial text", window, cx);
-    });
-    let thread_id = panel.read_with(cx, |panel, cx| panel.active_thread_id(cx).unwrap());
-    cx.run_until_parked();
-
-    // The thread without a title should show the editor text via
-    // the draft title override.
-    sidebar.read_with(cx, |sidebar, _cx| {
-        let thread = sidebar
-            .contents
-            .entries
-            .iter()
-            .find_map(|entry| match entry {
-                ListEntry::Thread(t) if t.metadata.thread_id == thread_id => Some(t),
-                _ => None,
-            });
-        assert_eq!(
-            thread.and_then(|t| t.metadata.title.as_ref().map(|s| s.as_ref())),
-            Some("Initial text"),
-            "draft title should show editor text before folder add"
-        );
-    });
-
-    // Add a second folder to the project — this changes the group key.
-    project
-        .update(cx, |project, cx| {
-            project.find_or_create_worktree("/project-b", true, cx)
-        })
-        .await
-        .expect("should add worktree");
-    cx.run_until_parked();
-
-    // Update editor text.
-    editor.update_in(cx, |editor, window, cx| {
-        editor.set_text("Updated after folder add", window, cx);
-    });
-    cx.run_until_parked();
-
-    // The draft title should still update. After adding a folder the
-    // group key changes, so the thread may not appear in the sidebar
-    // if its metadata was saved under the old path list. If it IS
-    // found, verify the title was overridden.
-    sidebar.read_with(cx, |sidebar, _cx| {
-        let thread = sidebar
-            .contents
-            .entries
-            .iter()
-            .find_map(|entry| match entry {
-                ListEntry::Thread(t) if t.metadata.thread_id == thread_id => Some(t),
-                _ => None,
-            });
-        if let Some(thread) = thread {
-            assert_eq!(
-                thread.metadata.title.as_ref().map(|s| s.as_ref()),
-                Some("Updated after folder add"),
-                "draft title should update even after adding a folder"
-            );
-        }
-    });
-}
-
 #[gpui::test]
 async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
     // When the user presses Cmd-N (NewThread action) while viewing a
-    // non-empty thread, the sidebar should show the "New Thread" entry.
-    // This exercises the same code path as the workspace action handler
-    // (which bypasses the sidebar's create_new_thread method).
+    // non-empty thread, the panel should switch to the draft thread.
+    // Drafts are not shown as sidebar rows.
     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));
@@ -3175,140 +2916,25 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
     });
     cx.run_until_parked();
 
+    // Drafts are not shown as sidebar rows, so entries stay the same.
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec!["v [my-project]", "  [~ Draft] *", "  Hello *"],
-        "After Cmd-N the sidebar should show a highlighted Draft entry"
+        vec!["v [my-project]", "  Hello *"],
+        "After Cmd-N the sidebar should not show a Draft entry"
     );
 
-    sidebar.read_with(cx, |sidebar, _cx| {
-        assert_active_draft(
-            sidebar,
-            &workspace,
-            "active_entry should be Draft after Cmd-N",
+    // The panel should be on the draft and active_entry should track it.
+    panel.read_with(cx, |panel, cx| {
+        assert!(
+            panel.active_thread_is_draft(cx),
+            "panel should be showing the draft after Cmd-N",
         );
     });
-}
-
-#[gpui::test]
-async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext) {
-    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 workspace has history.
-    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 saved_session_id = active_session_id(&panel, cx);
-    save_test_thread_metadata(&saved_session_id, &project, cx).await;
-    cx.run_until_parked();
-
-    assert_eq!(
-        visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [my-project]",
-            "  Hello *",
-        ]
-    );
-
-    // 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] *", "  Hello *"],
-    );
-
-    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
     sidebar.read_with(cx, |sidebar, _cx| {
         assert_active_draft(
             sidebar,
             &workspace,
-            "Draft with server session should be Draft, not Thread",
-        );
-    });
-}
-
-#[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 thread_id = panel.read_with(cx, |panel, cx| panel.active_thread_id(cx).unwrap());
-    panel.update_in(cx, |panel, _window, cx| {
-        panel.remove_thread(thread_id, cx);
-    });
-    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",
+            "active_entry should be Draft after Cmd-N",
         );
     });
 }
@@ -3316,8 +2942,8 @@ async fn test_sending_message_from_draft_removes_draft(cx: &mut TestAppContext)
 #[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
-    // should still show the "New Thread" entry under the main repo's
-    // header and highlight it as active.
+    // should activate the draft thread in the panel. Drafts are not
+    // shown as sidebar rows.
     agent_ui::test_support::init_test(cx);
     cx.update(|cx| {
         ThreadStore::init_global(cx);
@@ -3411,18 +3037,24 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp
     });
     cx.run_until_parked();
 
+    // Drafts are not shown as sidebar rows, so entries stay the same.
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
         vec![
             //
             "v [project]",
-            "  [~ 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"
+        "After Cmd-N the sidebar should not show a Draft entry"
     );
 
+    // The panel should be on the draft and active_entry should track it.
+    worktree_panel.read_with(cx, |panel, cx| {
+        assert!(
+            panel.active_thread_is_draft(cx),
+            "panel should be showing the draft after Cmd-N",
+        );
+    });
     sidebar.read_with(cx, |sidebar, _cx| {
         assert_active_draft(
             sidebar,
@@ -4298,7 +3930,6 @@ async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_proje
                 {
                     saw_expected_thread = true;
                 }
-                ListEntry::Thread(thread) if thread.is_draft => {}
                 ListEntry::Thread(thread) => {
                     let title = thread.metadata.display_title();
                     let worktree_name = thread
@@ -6462,11 +6093,13 @@ async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
     });
     cx.run_until_parked();
 
-    // Archiving the active thread clears active_entry (no draft is created).
+    // Archiving the active thread activates a draft on the same workspace
+    // (via clear_base_view → activate_draft). The draft is not shown as a
+    // sidebar row but active_entry tracks it.
     sidebar.read_with(cx, |sidebar, _| {
         assert!(
-            sidebar.active_entry.is_none(),
-            "expected None after archiving active thread, got: {:?}",
+            matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b),
+            "expected draft on workspace_b after archiving active thread, got: {:?}",
             sidebar.active_entry,
         );
     });
@@ -6929,8 +6562,6 @@ async fn test_unarchive_into_inactive_existing_workspace_does_not_leave_active_d
     });
     let immediate_active_thread_id =
         panel_b_before_settle.read_with(cx, |panel, cx| panel.active_thread_id(cx));
-    let immediate_draft_ids =
-        panel_b_before_settle.read_with(cx, |panel, cx| panel.draft_thread_ids(cx));
 
     cx.run_until_parked();
 
@@ -6954,7 +6585,7 @@ async fn test_unarchive_into_inactive_existing_workspace_does_not_leave_active_d
     );
     assert!(
         immediate_active_thread_id.is_none() || immediate_active_thread_id == Some(thread_id),
-        "expected immediate panel state to be either still loading or already on the restored thread, got active_thread_id={immediate_active_thread_id:?}, draft_ids={immediate_draft_ids:?}"
+        "expected immediate panel state to be either still loading or already on the restored thread, got active_thread_id={immediate_active_thread_id:?}"
     );
 
     let entries = visible_entries_as_strings(&sidebar, cx);
@@ -7197,7 +6828,7 @@ async fn test_unarchive_does_not_create_duplicate_real_thread_metadata(cx: &mut
         "expected unarchive to reuse the original thread id instead of creating a duplicate row"
     );
     assert!(
-        !session_entries[0].is_draft(),
+        session_entries[0].session_id.is_some(),
         "expected restored metadata to be a real thread, got: {:?}",
         session_entries[0]
     );
@@ -7223,8 +6854,8 @@ async fn test_switch_to_workspace_with_archived_thread_shows_no_active_entry(
     cx: &mut TestAppContext,
 ) {
     // When a thread is archived while the user is in a different workspace,
-    // the group is left empty (no draft is created). Switching back to that
-    // workspace should show no active entry.
+    // clear_base_view creates a draft on the archived workspace's panel.
+    // Switching back to that workspace shows the draft as active_entry.
     agent_ui::test_support::init_test(cx);
     cx.update(|cx| {
         ThreadStore::init_global(cx);
@@ -7269,8 +6900,9 @@ async fn test_switch_to_workspace_with_archived_thread_shows_no_active_entry(
     });
     cx.run_until_parked();
 
-    // Switch back to project-a. Its panel was cleared during archiving,
-    // so active_entry should be None (no draft is created).
+    // Switch back to project-a. Its panel was cleared during archiving
+    // (clear_base_view activated a draft), so active_entry should point
+    // to the draft on workspace_a.
     let workspace_a =
         multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
     multi_workspace.update_in(cx, |mw, window, cx| {
@@ -7284,10 +6916,10 @@ async fn test_switch_to_workspace_with_archived_thread_shows_no_active_entry(
     cx.run_until_parked();
 
     sidebar.read_with(cx, |sidebar, _| {
-        assert!(
-            sidebar.active_entry.is_none(),
-            "expected no active entry after switching to workspace with archived thread, got: {:?}",
-            sidebar.active_entry,
+        assert_active_draft(
+            sidebar,
+            &workspace_a,
+            "after switching to workspace with archived thread, active_entry should be the draft",
         );
     });
 }
@@ -7974,13 +7606,11 @@ async fn test_archive_thread_on_linked_worktree_selects_sibling_thread(cx: &mut
     );
 }
 
+// TODO: Restore this test once linked worktree draft entries are re-implemented.
+// The draft-in-sidebar approach was reverted in favor of just the + button toggle.
 #[gpui::test]
+#[ignore = "linked worktree draft entries not yet implemented"]
 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
-    // 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());
 
@@ -8083,51 +7713,8 @@ async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestA
     );
 
     // Find the draft Thread entry whose workspace is the linked worktree.
-    let new_thread_ix = sidebar.read_with(cx, |sidebar, _| {
-        sidebar
-            .contents
-            .entries
-            .iter()
-            .position(|entry| match entry {
-                ListEntry::Thread(thread) if thread.is_draft => matches!(
-                    &thread.workspace,
-                    ThreadEntryWorkspace::Open(ws) if ws.entity_id() == worktree_ws_id
-                ),
-                _ => false,
-            })
-            .expect("expected a draft thread entry for the linked worktree")
-    });
-
-    assert_eq!(
-        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
-        2
-    );
-
-    sidebar.update_in(cx, |sidebar, window, cx| {
-        sidebar.selection = Some(new_thread_ix);
-        sidebar.remove_selected_thread(&RemoveSelectedThread, window, cx);
-    });
-    cx.run_until_parked();
-
-    assert_eq!(
-        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
-        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| match entry {
-            ListEntry::Thread(thread) if thread.is_draft => matches!(
-                &thread.workspace,
-                ThreadEntryWorkspace::Open(ws) if ws.entity_id() == worktree_ws_id
-            ),
-            _ => false,
-        })
-    });
-    assert!(
-        !has_draft_for_worktree,
-        "draft thread entry for the linked worktree should be removed after dismiss"
-    );
+    let _ = (worktree_ws_id, sidebar, multi_workspace);
+    // todo("re-implement once linked worktree draft entries exist");
 }
 
 #[gpui::test]
@@ -8812,53 +8399,6 @@ async fn test_project_header_click_restores_last_viewed(cx: &mut TestAppContext)
     );
 }
 
-#[gpui::test]
-async fn test_plus_button_reuses_empty_draft(cx: &mut TestAppContext) {
-    // Clicking the + button when an empty draft already exists should
-    // focus the existing draft rather than creating a new one.
-    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: no drafts from reconciliation.
-    let entries = visible_entries_as_strings(&sidebar, cx);
-    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
-    assert_eq!(draft_count, 0, "should start with 0 drafts");
-
-    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: should create a 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 should create a draft");
-
-    // Second + click with empty draft: should reuse it, not create a new one.
-    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,
-        "second + click should reuse the existing empty draft, not create a new one"
-    );
-
-    // The draft should be active.
-    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)
@@ -9905,8 +9445,8 @@ mod property_test {
                 .unwrap_or_default();
 
             // Main code path queries (run for all groups, even without workspaces).
-            // Skip drafts (session_id: None) — they are shown via the
-            // panel's draft_thread_ids, not by session_id matching.
+            // Skip drafts (session_id: None) — they are not shown in the
+            // sidebar entries.
             for metadata in thread_store
                 .read(cx)
                 .entries_for_main_worktree_path(&path_list, None)
@@ -10042,7 +9582,15 @@ mod property_test {
         }
 
         // 4. Exactly one entry in sidebar contents must be uniquely
-        //    identified by the active_entry.
+        //    identified by the active_entry — unless the panel is showing
+        //    a draft, which is represented by the + button's active state
+        //    rather than a sidebar row.
+        // TODO: Make this check more complete
+        let is_draft = panel.read(cx).active_thread_is_draft(cx)
+            || panel.read(cx).active_conversation_view().is_none();
+        if is_draft {
+            return Ok(());
+        }
         let matching_count = sidebar
             .contents
             .entries
@@ -10056,8 +9604,8 @@ mod property_test {
                 .iter()
                 .filter_map(|e| match e {
                     ListEntry::Thread(t) => Some(format!(
-                        "tid={:?} sid={:?} draft={}",
-                        t.metadata.thread_id, t.metadata.session_id, t.is_draft
+                        "tid={:?} sid={:?}",
+                        t.metadata.thread_id, t.metadata.session_id
                     )),
                     _ => None,
                 })