Add more functionality to the draft system

Danilo Leal , Nathan Sobo , and Mikayla Maki created

Co-authored-by: Nathan Sobo <nathan@zed.dev>
Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>

Change summary

crates/agent_ui/src/agent_panel.rs             |  29 
crates/agent_ui/src/thread_worktree_archive.rs |  21 
crates/sidebar/src/sidebar.rs                  | 344 ++++++++------
crates/sidebar/src/sidebar_tests.rs            | 466 ++++++++++++++++++-
4 files changed, 656 insertions(+), 204 deletions(-)

Detailed changes

crates/agent_ui/src/agent_panel.rs 🔗

@@ -1322,11 +1322,6 @@ impl AgentPanel {
         self.activate_draft(id, true, window, cx);
     }
 
-    pub fn new_empty_thread(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        self.reset_start_thread_in_to_default(cx);
-        self.external_thread(None, None, None, None, None, true, window, cx);
-    }
-
     /// Creates a new empty draft thread and stores it. Returns the DraftId.
     /// The draft is NOT activated — call `activate_draft` to show it.
     pub fn create_draft(&mut self, window: &mut Window, cx: &mut Context<Self>) -> DraftId {
@@ -1401,6 +1396,20 @@ impl AgentPanel {
         }
     }
 
+    /// Clears the message editor text of a tracked draft.
+    pub fn clear_draft_editor(&self, id: DraftId, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(cv) = self.draft_threads.get(&id) else {
+            return;
+        };
+        let Some(tv) = cv.read(cx).active_thread() else {
+            return;
+        };
+        let editor = tv.read(cx).message_editor.clone();
+        editor.update(cx, |editor, cx| {
+            editor.clear(window, cx);
+        });
+    }
+
     fn take_active_draft_initial_content(
         &mut self,
         cx: &mut Context<Self>,
@@ -2293,6 +2302,12 @@ impl AgentPanel {
                         this.handle_first_send_requested(view.clone(), content.clone(), window, cx);
                     }
                     AcpThreadViewEvent::MessageSentOrQueued => {
+                        // When a draft sends its first message it becomes a
+                        // real thread. Remove it from `draft_threads` so the
+                        // sidebar stops showing a stale draft entry.
+                        if let Some(draft_id) = this.active_draft_id() {
+                            this.draft_threads.remove(&draft_id);
+                        }
                         let session_id = view.read(cx).thread.read(cx).session_id().clone();
                         cx.emit(AgentPanelEvent::MessageSentOrQueued { session_id });
                     }
@@ -3556,8 +3571,8 @@ impl Panel for AgentPanel {
                 Some((_, WorktreeCreationStatus::Creating))
             )
         {
-            let selected_agent = self.selected_agent.clone();
-            self.new_agent_thread_inner(selected_agent, false, window, cx);
+            let id = self.create_draft(window, cx);
+            self.activate_draft(id, false, window, cx);
         }
     }
 

crates/agent_ui/src/thread_worktree_archive.rs 🔗

@@ -139,16 +139,6 @@ pub fn build_root_plan(
             .then_some((snapshot, repo))
         });
 
-    let matching_worktree_snapshot = workspaces.iter().find_map(|workspace| {
-        workspace
-            .read(cx)
-            .project()
-            .read(cx)
-            .visible_worktrees(cx)
-            .find(|worktree| worktree.read(cx).abs_path().as_ref() == path.as_path())
-            .map(|worktree| worktree.read(cx).snapshot())
-    });
-
     let (main_repo_path, worktree_repo, branch_name) =
         if let Some((linked_snapshot, repo)) = linked_repo {
             (
@@ -160,12 +150,11 @@ pub fn build_root_plan(
                     .map(|branch| branch.name().to_string()),
             )
         } else {
-            let main_repo_path = matching_worktree_snapshot
-                .as_ref()?
-                .root_repo_common_dir()
-                .and_then(|dir| dir.parent())?
-                .to_path_buf();
-            (main_repo_path, None, None)
+            // Not a linked worktree — nothing to archive from disk.
+            // `remove_root` would try to remove the main worktree from
+            // the project and then run `git worktree remove`, both of
+            // which fail for main working trees.
+            return None;
         };
 
     Some(RootPlan {

crates/sidebar/src/sidebar.rs 🔗

@@ -122,9 +122,7 @@ enum ActiveEntry {
         workspace: Entity<Workspace>,
     },
     Draft {
-        /// `None` for untracked drafts (e.g., from Cmd-N keyboard shortcut
-        /// that goes directly through the AgentPanel).
-        id: Option<DraftId>,
+        id: DraftId,
         workspace: Entity<Workspace>,
     },
 }
@@ -142,7 +140,7 @@ impl ActiveEntry {
     }
 
     fn is_active_draft(&self, draft_id: DraftId) -> bool {
-        matches!(self, ActiveEntry::Draft { id: Some(id), .. } if *id == draft_id)
+        matches!(self, ActiveEntry::Draft { id, .. } if *id == draft_id)
     }
 
     fn matches_entry(&self, entry: &ListEntry) -> bool {
@@ -151,23 +149,12 @@ impl ActiveEntry {
                 thread.metadata.session_id == *session_id
             }
             (
-                ActiveEntry::Draft {
-                    id,
-                    workspace: active_ws,
-                },
+                ActiveEntry::Draft { id, .. },
                 ListEntry::DraftThread {
-                    draft_id,
-                    workspace: entry_ws,
+                    draft_id: Some(entry_id),
                     ..
                 },
-            ) => match (id, draft_id) {
-                // Both have DraftIds — compare directly.
-                (Some(active_id), Some(entry_id)) => *active_id == *entry_id,
-                // Both untracked — match by workspace identity.
-                (None, None) => entry_ws.as_ref().is_some_and(|ws| ws == active_ws),
-                // Mixed tracked/untracked — never match.
-                _ => false,
-            },
+            ) => *id == *entry_id,
             _ => false,
         }
     }
@@ -691,8 +678,6 @@ impl Sidebar {
             window,
             |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event {
                 AgentPanelEvent::ActiveViewChanged => {
-                    // active_entry is fully derived during
-                    // rebuild_contents — just trigger a rebuild.
                     this.observe_draft_editor(cx);
                     this.update_entries(cx);
                 }
@@ -812,6 +797,42 @@ impl Sidebar {
         .detach_and_log_err(cx);
     }
 
+    fn open_workspace_and_create_draft(
+        &mut self,
+        project_group_key: &ProjectGroupKey,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
+            return;
+        };
+
+        let path_list = project_group_key.path_list().clone();
+        let host = project_group_key.host();
+        let provisional_key = Some(project_group_key.clone());
+        let active_workspace = multi_workspace.read(cx).workspace().clone();
+
+        let task = multi_workspace.update(cx, |this, cx| {
+            this.find_or_create_workspace(
+                path_list,
+                host,
+                provisional_key,
+                |options, window, cx| connect_remote(active_workspace, options, window, cx),
+                window,
+                cx,
+            )
+        });
+
+        cx.spawn_in(window, async move |this, cx| {
+            let workspace = task.await?;
+            this.update_in(cx, |this, window, cx| {
+                this.create_new_thread(&workspace, window, cx);
+            })?;
+            anyhow::Ok(())
+        })
+        .detach_and_log_err(cx);
+    }
+
     /// Rebuilds the sidebar contents from current workspace and thread state.
     ///
     /// Iterates [`MultiWorkspace::project_group_keys`] to determine project
@@ -842,56 +863,21 @@ impl Sidebar {
         let query = self.filter_editor.read(cx).text(cx);
 
         // Derive active_entry from the active workspace's agent panel.
-        // Draft is checked first because a conversation can have a session_id
-        // before any messages are sent. However, a thread that's still loading
-        // also appears as a "draft" (no messages yet).
+        // A tracked draft (in `draft_threads`) is checked first via
+        // `active_draft_id`. Then we check for a thread with a session_id.
+        // If a thread is mid-load with no session_id yet, we fall back to
+        // `pending_remote_thread_activation` or keep the previous value.
         if let Some(active_ws) = &active_workspace {
             if let Some(panel) = active_ws.read(cx).panel::<AgentPanel>(cx) {
-                let active_thread_is_draft = panel.read(cx).active_thread_is_draft(cx);
-                let active_conversation_view = panel.read(cx).active_conversation_view();
-
-                if active_thread_is_draft || active_conversation_view.is_none() {
-                    if active_conversation_view.is_none()
-                        && let Some(session_id) = self.pending_remote_thread_activation.clone()
-                    {
-                        self.active_entry = Some(ActiveEntry::Thread {
-                            session_id,
-                            workspace: active_ws.clone(),
-                        });
-                    } else {
-                        let conversation_parent_id =
-                            active_conversation_view.and_then(|cv| cv.read(cx).parent_id(cx));
-                        let preserving_thread = if let Some(ActiveEntry::Thread {
-                            session_id,
-                            ..
-                        }) = &self.active_entry
-                        {
-                            self.active_entry_workspace() == Some(active_ws)
-                                && conversation_parent_id
-                                    .as_ref()
-                                    .is_some_and(|id| id == session_id)
-                        } else {
-                            false
-                        } || self
-                            .pending_remote_thread_activation
-                            .is_some();
-
-                        if !preserving_thread {
-                            // The active panel shows a draft. Read
-                            // the draft ID from the AgentPanel (may be
-                            // None for untracked drafts from Cmd-N).
-                            let draft_id = active_ws
-                                .read(cx)
-                                .panel::<AgentPanel>(cx)
-                                .and_then(|p| p.read(cx).active_draft_id());
-                            self.active_entry = Some(ActiveEntry::Draft {
-                                id: draft_id,
-                                workspace: active_ws.clone(),
-                            });
-                        }
-                    }
-                } else if let Some(session_id) =
-                    active_conversation_view.and_then(|cv| cv.read(cx).parent_id(cx))
+                let panel = panel.read(cx);
+                if let Some(draft_id) = panel.active_draft_id() {
+                    self.active_entry = Some(ActiveEntry::Draft {
+                        id: draft_id,
+                        workspace: active_ws.clone(),
+                    });
+                } else if let Some(session_id) = panel
+                    .active_conversation_view()
+                    .and_then(|cv| cv.read(cx).parent_id(cx))
                 {
                     if self.pending_remote_thread_activation.as_ref() == Some(&session_id) {
                         self.pending_remote_thread_activation = None;
@@ -900,9 +886,13 @@ impl Sidebar {
                         session_id,
                         workspace: active_ws.clone(),
                     });
+                } else if let Some(session_id) = self.pending_remote_thread_activation.clone() {
+                    self.active_entry = Some(ActiveEntry::Thread {
+                        session_id,
+                        workspace: active_ws.clone(),
+                    });
                 }
-                // else: conversation exists, not a draft, but no session_id
-                // yet — thread is mid-load. Keep previous value.
+                // else: conversation is mid-load (no session_id yet), keep previous active_entry
             }
         }
 
@@ -1239,13 +1229,7 @@ impl Sidebar {
                     for ws in group_workspaces {
                         if let Some(panel) = ws.read(cx).panel::<AgentPanel>(cx) {
                             let ids = panel.read(cx).draft_ids();
-                            if !ids.is_empty() {
-                                dbg!(
-                                    "found drafts in panel",
-                                    group_key.display_name(&Default::default()),
-                                    ids.len()
-                                );
-                            }
+
                             for draft_id in ids {
                                 group_draft_ids.push((draft_id, ws.clone()));
                             }
@@ -1672,9 +1656,6 @@ impl Sidebar {
                         let key = key.clone();
                         let focus_handle = self.focus_handle.clone();
 
-                        // TODO DL: Hitting this button for the first time after compiling the app on a non-activated workspace
-                        // is currently NOT creating a draft. It activates the workspace but it requires a second click to
-                        // effectively create the draft.
                         IconButton::new(
                             SharedString::from(format!(
                                 "{id_prefix}project-header-new-thread-{ix}",
@@ -1683,24 +1664,38 @@ impl Sidebar {
                         )
                         .icon_size(IconSize::Small)
                         .tooltip(move |_, cx| {
-                            Tooltip::for_action_in("New Thread", &NewThread, &focus_handle, cx)
+                            Tooltip::for_action_in(
+                                "Start New Agent Thread",
+                                &NewThread,
+                                &focus_handle,
+                                cx,
+                            )
                         })
                         .on_click(cx.listener(
                             move |this, _, window, cx| {
                                 this.collapsed_groups.remove(&key);
                                 this.selection = None;
-                                if let Some(workspace) =
-                                    this.multi_workspace.upgrade().and_then(|mw| {
-                                        mw.read(cx).workspace_for_paths(
+                                // If the active workspace belongs to this
+                                // group, use it (preserves linked worktree
+                                // context). Otherwise resolve from the key.
+                                let workspace = this.multi_workspace.upgrade().and_then(|mw| {
+                                    let mw = mw.read(cx);
+                                    let active = mw.workspace().clone();
+                                    let active_key = active.read(cx).project_group_key(cx);
+                                    if active_key == key {
+                                        Some(active)
+                                    } else {
+                                        mw.workspace_for_paths(
                                             key.path_list(),
                                             key.host().as_ref(),
                                             cx,
                                         )
-                                    })
-                                {
+                                    }
+                                });
+                                if let Some(workspace) = workspace {
                                     this.create_new_thread(&workspace, window, cx);
                                 } else {
-                                    this.open_workspace_for_group(&key, window, cx);
+                                    this.open_workspace_and_create_draft(&key, window, cx);
                                 }
                             },
                         ))
@@ -1722,17 +1717,15 @@ impl Sidebar {
                                     cx,
                                 )
                             }) {
-                                // Find an existing draft for this group
-                                // and activate it, rather than creating
-                                // a new one.
-                                let draft_id = workspace
-                                    .read(cx)
-                                    .panel::<AgentPanel>(cx)
-                                    .and_then(|p| p.read(cx).draft_ids().first().copied());
-                                if let Some(draft_id) = draft_id {
-                                    this.activate_draft(draft_id, &workspace, window, cx);
-                                } else {
-                                    this.create_new_thread(&workspace, window, cx);
+                                // Just activate the workspace. The
+                                // AgentPanel remembers what was last
+                                // shown, so the user returns to whatever
+                                // thread/draft they were looking at.
+                                this.activate_workspace(&workspace, window, cx);
+                                if AgentPanel::is_visible(&workspace, cx) {
+                                    workspace.update(cx, |workspace, cx| {
+                                        workspace.focus_panel::<AgentPanel>(window, cx);
+                                    });
                                 }
                             } else {
                                 this.open_workspace_for_group(&key, window, cx);
@@ -1939,7 +1932,7 @@ impl Sidebar {
         let color = cx.theme().colors();
         let background = color
             .title_bar_background
-            .blend(color.panel_background.opacity(0.25));
+            .blend(color.panel_background.opacity(0.2));
 
         let element = v_flex()
             .absolute()
@@ -2190,6 +2183,8 @@ impl Sidebar {
                     if let Some(workspace) = workspace {
                         self.activate_draft(draft_id, &workspace, window, cx);
                     }
+                } else if let Some(workspace) = workspace {
+                    self.activate_workspace(&workspace, window, cx);
                 } else {
                     self.open_workspace_for_group(&key, window, cx);
                 }
@@ -2828,22 +2823,20 @@ impl Sidebar {
                 .entries_for_path(folder_paths)
                 .filter(|t| t.session_id != *session_id)
                 .count();
+
             if remaining > 0 {
                 return None;
             }
 
             let multi_workspace = self.multi_workspace.upgrade()?;
-            // Thread metadata doesn't carry host info yet, so we pass
-            // `None` here. This may match a local workspace with the same
-            // paths instead of the intended remote one.
             let workspace = multi_workspace
                 .read(cx)
                 .workspace_for_paths(folder_paths, None, cx)?;
 
-            // Don't remove the main worktree workspace — the project
-            // header always provides access to it.
             let group_key = workspace.read(cx).project_group_key(cx);
-            (group_key.path_list() != folder_paths).then_some(workspace)
+            let is_linked_worktree = group_key.path_list() != folder_paths;
+
+            is_linked_worktree.then_some(workspace)
         });
 
         if let Some(workspace_to_remove) = workspace_to_remove {
@@ -2896,7 +2889,6 @@ impl Sidebar {
             })
             .detach_and_log_err(cx);
         } else {
-            // Simple case: no workspace removal needed.
             let neighbor_metadata = neighbor.map(|(metadata, _)| metadata);
             let in_flight = self.start_archive_worktree_task(session_id, roots_to_archive, cx);
             self.archive_and_activate(
@@ -2962,7 +2954,11 @@ impl Sidebar {
                             .is_some_and(|id| id == *session_id);
                         if panel_shows_archived {
                             panel.update(cx, |panel, cx| {
-                                panel.clear_active_thread(window, cx);
+                                // Replace the archived thread with a
+                                // tracked draft so the panel isn't left
+                                // in Uninitialized state.
+                                let id = panel.create_draft(window, cx);
+                                panel.activate_draft(id, false, window, cx);
                             });
                         }
                     }
@@ -2975,6 +2971,7 @@ impl Sidebar {
         // tell the panel to load it and activate that workspace.
         // `rebuild_contents` will reconcile `active_entry` once the thread
         // finishes loading.
+
         if let Some(metadata) = neighbor {
             if let Some(workspace) = self.multi_workspace.upgrade().and_then(|mw| {
                 mw.read(cx)
@@ -2994,8 +2991,6 @@ impl Sidebar {
             .and_then(|folder_paths| {
                 let mw = self.multi_workspace.upgrade()?;
                 let mw = mw.read(cx);
-                // Find the group's main workspace (whose root paths match
-                // the project group key, not the thread's folder paths).
                 let thread_workspace = mw.workspace_for_paths(folder_paths, None, cx)?;
                 let group_key = thread_workspace.read(cx).project_group_key(cx);
                 mw.workspace_for_paths(group_key.path_list(), None, cx)
@@ -3681,30 +3676,13 @@ impl Sidebar {
         // If there is a keyboard selection, walk backwards through
         // `project_header_indices` to find the header that owns the selected
         // row. Otherwise fall back to the active workspace.
-        let workspace = if let Some(selected_ix) = self.selection {
-            self.contents
-                .project_header_indices
-                .iter()
-                .rev()
-                .find(|&&header_ix| header_ix <= selected_ix)
-                .and_then(|&header_ix| match &self.contents.entries[header_ix] {
-                    ListEntry::ProjectHeader { key, .. } => {
-                        self.multi_workspace.upgrade().and_then(|mw| {
-                            mw.read(cx).workspace_for_paths(
-                                key.path_list(),
-                                key.host().as_ref(),
-                                cx,
-                            )
-                        })
-                    }
-                    _ => None,
-                })
-        } else {
-            // Use the currently active workspace.
-            self.multi_workspace
-                .upgrade()
-                .map(|mw| mw.read(cx).workspace().clone())
-        };
+        // Always use the currently active workspace so that drafts
+        // are created in the linked worktree the user is focused on,
+        // not the main worktree resolved from the project header.
+        let workspace = self
+            .multi_workspace
+            .upgrade()
+            .map(|mw| mw.read(cx).workspace().clone());
 
         let Some(workspace) = workspace else {
             return;
@@ -3743,7 +3721,7 @@ impl Sidebar {
 
         if let Some(draft_id) = draft_id {
             self.active_entry = Some(ActiveEntry::Draft {
-                id: Some(draft_id),
+                id: draft_id,
                 workspace: workspace.clone(),
             });
         }
@@ -3772,7 +3750,7 @@ impl Sidebar {
         });
 
         self.active_entry = Some(ActiveEntry::Draft {
-            id: Some(draft_id),
+            id: draft_id,
             workspace: workspace.clone(),
         });
 
@@ -3803,14 +3781,15 @@ impl Sidebar {
             let mut switched = false;
             let group_key = workspace.read(cx).project_group_key(cx);
 
-            // Try the nearest draft in the same panel (prefer the
-            // next one in creation order, fall back to the previous).
+            // Try the next draft below in the sidebar (smaller ID
+            // since the list is newest-first). Fall back to the one
+            // above (larger ID) if the deleted draft was last.
             if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
                 let ids = panel.read(cx).draft_ids();
                 let sibling = ids
                     .iter()
-                    .find(|id| id.0 > draft_id.0)
-                    .or_else(|| ids.last());
+                    .find(|id| id.0 < draft_id.0)
+                    .or_else(|| ids.first());
                 if let Some(&sibling_id) = sibling {
                     self.activate_draft(sibling_id, workspace, window, cx);
                     switched = true;
@@ -3843,31 +3822,50 @@ impl Sidebar {
         self.update_entries(cx);
     }
 
-    /// Reads a draft's prompt text from its ConversationView in the AgentPanel.
-    fn read_draft_text(
-        &self,
+    fn clear_draft(
+        &mut self,
         draft_id: DraftId,
         workspace: &Entity<Workspace>,
-        cx: &App,
-    ) -> Option<SharedString> {
-        let panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
-        let raw = panel.read(cx).draft_editor_text(draft_id, cx)?;
-        let cleaned = Self::clean_mention_links(&raw);
-        let mut text: String = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        workspace.update(cx, |ws, cx| {
+            if let Some(panel) = ws.panel::<AgentPanel>(cx) {
+                panel.update(cx, |panel, cx| {
+                    panel.clear_draft_editor(draft_id, window, cx);
+                });
+            }
+        });
+        self.update_entries(cx);
+    }
 
+    /// Cleans, collapses whitespace, and truncates raw editor text
+    /// for display as a draft label in the sidebar.
+    fn truncate_draft_label(raw: &str) -> Option<SharedString> {
+        let cleaned = Self::clean_mention_links(raw);
+        let mut text: String = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
         if text.is_empty() {
             return None;
         }
-
         const MAX_CHARS: usize = 250;
-
         if let Some((truncate_at, _)) = text.char_indices().nth(MAX_CHARS) {
             text.truncate(truncate_at);
         }
-
         Some(text.into())
     }
 
+    /// Reads a draft's prompt text from its ConversationView in the AgentPanel.
+    fn read_draft_text(
+        &self,
+        draft_id: DraftId,
+        workspace: &Entity<Workspace>,
+        cx: &App,
+    ) -> Option<SharedString> {
+        let panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
+        let raw = panel.read(cx).draft_editor_text(draft_id, cx)?;
+        Self::truncate_draft_label(&raw)
+    }
+
     fn active_project_group_key(&self, cx: &App) -> Option<ProjectGroupKey> {
         let multi_workspace = self.multi_workspace.upgrade()?;
         let multi_workspace = multi_workspace.read(cx);
@@ -4112,9 +4110,10 @@ impl Sidebar {
     ) -> AnyElement {
         let label: SharedString = draft_id
             .and_then(|id| workspace.and_then(|ws| self.read_draft_text(id, ws, cx)))
-            .unwrap_or_else(|| "Draft Thread".into());
+            .unwrap_or_else(|| "New Agent Thread".into());
 
         let id = SharedString::from(format!("draft-thread-btn-{}", ix));
+
         let worktrees = worktrees
             .iter()
             .map(|worktree| ThreadItemWorktreeInfo {
@@ -4126,9 +4125,11 @@ impl Sidebar {
             .collect();
 
         let is_hovered = self.hovered_thread_index == Some(ix);
+
         let key = key.clone();
         let workspace_for_click = workspace.cloned();
         let workspace_for_remove = workspace.cloned();
+        let workspace_for_clear = workspace.cloned();
 
         ThreadItem::new(id, label)
             .icon(IconName::Pencil)
@@ -4150,13 +4151,22 @@ impl Sidebar {
                     if let Some(workspace) = &workspace_for_click {
                         this.activate_draft(draft_id, workspace, window, cx);
                     }
+                } else if let Some(workspace) = &workspace_for_click {
+                    // Placeholder with an open workspace — just
+                    // activate it. The panel remembers its last view.
+                    this.activate_workspace(workspace, window, cx);
+                    if AgentPanel::is_visible(workspace, cx) {
+                        workspace.update(cx, |ws, cx| {
+                            ws.focus_panel::<AgentPanel>(window, cx);
+                        });
+                    }
                 } else {
-                    // Placeholder for a group with no workspace — open it.
+                    // No workspace at all — just open one. The
+                    // panel's load fallback will create a draft.
                     this.open_workspace_for_group(&key, window, cx);
                 }
             }))
-            .when(can_dismiss && draft_id.is_some(), |this| {
-                let draft_id = draft_id.unwrap();
+            .when_some(draft_id.filter(|_| can_dismiss), |this, draft_id| {
                 this.action_slot(
                     div()
                         .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
@@ -4180,6 +4190,30 @@ impl Sidebar {
                         ),
                 )
             })
+            .when_some(draft_id.filter(|_| !can_dismiss), |this, draft_id| {
+                this.action_slot(
+                    div()
+                        .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
+                            cx.stop_propagation();
+                        })
+                        .child(
+                            IconButton::new(
+                                SharedString::from(format!("clear-draft-{}", ix)),
+                                IconName::Close,
+                            )
+                            .icon_size(IconSize::Small)
+                            .icon_color(Color::Muted)
+                            .tooltip(Tooltip::text("Clear Draft"))
+                            .on_click(cx.listener(
+                                move |this, _, window, cx| {
+                                    if let Some(workspace) = &workspace_for_clear {
+                                        this.clear_draft(draft_id, workspace, window, cx);
+                                    }
+                                },
+                            )),
+                        ),
+                )
+            })
             .into_any_element()
     }
 

crates/sidebar/src/sidebar_tests.rs 🔗

@@ -340,11 +340,6 @@ fn visible_entries_as_strings(
                 } else {
                     ""
                 };
-                let is_active = sidebar
-                    .active_entry
-                    .as_ref()
-                    .is_some_and(|active| active.matches_entry(entry));
-                let active_indicator = if is_active { " (active)" } else { "" };
                 match entry {
                     ListEntry::ProjectHeader {
                         label,
@@ -377,7 +372,7 @@ fn visible_entries_as_strings(
                             ""
                         };
                         let worktree = format_linked_worktree_chips(&thread.worktrees);
-                        format!("  {title}{worktree}{live}{status_str}{notified}{active_indicator}{selected}")
+                        format!("  {title}{worktree}{live}{status_str}{notified}{selected}")
                     }
                     ListEntry::ViewMore {
                         is_fully_expanded, ..
@@ -1465,7 +1460,7 @@ async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
         vec![
             //
             "v [my-project]",
-            "  Hello * (active)",
+            "  Hello *",
             "  Hello * (running)",
         ]
     );
@@ -1563,7 +1558,7 @@ async fn test_background_thread_completion_triggers_notification(cx: &mut TestAp
         vec![
             //
             "v [project-a]",
-            "  Hello * (running) (active)",
+            "  Hello * (running)",
         ]
     );
 
@@ -1577,7 +1572,7 @@ async fn test_background_thread_completion_triggers_notification(cx: &mut TestAp
         vec![
             //
             "v [project-a]",
-            "  Hello * (!) (active)",
+            "  Hello * (!)",
         ]
     );
 }
@@ -2269,7 +2264,7 @@ async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext)
         vec![
             //
             "v [my-project]",
-            "  Hello * (active)",
+            "  Hello *",
         ]
     );
 
@@ -2295,7 +2290,7 @@ async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext)
         vec![
             //
             "v [my-project]",
-            "  Friendly Greeting with AI * (active)",
+            "  Friendly Greeting with AI *",
         ]
     );
 }
@@ -2553,7 +2548,7 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex
         vec![
             //
             "v [project-a]",
-            "  Hello * (active)",
+            "  Hello *",
         ]
     );
 
@@ -2588,8 +2583,6 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex
         vec![
             "v [project-a, project-b]", //
             "  Hello *",
-            "v [project-a]",
-            "  [~ Draft]",
         ]
     );
 
@@ -3122,7 +3115,6 @@ async fn test_worktree_collision_keeps_active_workspace(cx: &mut TestAppContext)
         vec![
             //
             "v [project-a, project-b]",
-            "  [~ Draft] (active)",
             "  Thread B",
             "v [project-a]",
             "  Thread A",
@@ -3203,7 +3195,6 @@ async fn test_worktree_collision_keeps_active_workspace(cx: &mut TestAppContext)
         vec![
             //
             "v [project-a, project-b]",
-            "  [~ Draft] (active)",
             "  Thread A",
             "  Worktree Thread {project-a:wt-feature}",
             "  Thread B",
@@ -3323,7 +3314,6 @@ async fn test_worktree_add_syncs_linked_worktree_sibling(cx: &mut TestAppContext
         vec![
             //
             "v [project]",
-            "  [~ Draft {wt-feature}] (active)",
             "  Worktree Thread {wt-feature}",
             "  Main Thread",
         ]
@@ -3382,7 +3372,6 @@ async fn test_worktree_add_syncs_linked_worktree_sibling(cx: &mut TestAppContext
         vec![
             //
             "v [other-project, project]",
-            "  [~ Draft {project:wt-feature}] (active)",
             "  Worktree Thread {project:wt-feature}",
             "  Main Thread",
         ]
@@ -3417,7 +3406,7 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
         vec![
             //
             "v [my-project]",
-            "  Hello * (active)",
+            "  Hello *",
         ]
     );
 
@@ -3469,7 +3458,7 @@ async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext)
         vec![
             //
             "v [my-project]",
-            "  Hello * (active)",
+            "  Hello *",
         ]
     );
 
@@ -3495,6 +3484,72 @@ async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext)
     });
 }
 
+#[gpui::test]
+async fn test_sending_message_from_draft_removes_draft(cx: &mut TestAppContext) {
+    // When the user sends a message from a draft thread, the draft
+    // should be removed from the sidebar and the active_entry should
+    // transition to a Thread pointing at the new session.
+    let project = init_test_project_with_agent_panel("/my-project", cx).await;
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
+
+    // Create a saved thread so the group isn't empty.
+    let connection = StubAgentConnection::new();
+    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
+        acp::ContentChunk::new("Done".into()),
+    )]);
+    open_thread_with_connection(&panel, connection, cx);
+    send_message(&panel, cx);
+    let existing_session_id = active_session_id(&panel, cx);
+    save_test_thread_metadata(&existing_session_id, &project, cx).await;
+    cx.run_until_parked();
+
+    // Create a draft via Cmd-N.
+    panel.update_in(cx, |panel, window, cx| {
+        panel.new_thread(&NewThread, window, cx);
+    });
+    cx.run_until_parked();
+
+    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+    assert_eq!(
+        visible_entries_as_strings(&sidebar, cx),
+        vec!["v [my-project]", "  [~ Draft] *", "  Hello *"],
+        "draft should be visible before sending",
+    );
+    sidebar.read_with(cx, |sidebar, _| {
+        assert_active_draft(sidebar, &workspace, "should be on draft before sending");
+    });
+
+    // Now send a message from the draft. Set up the connection to
+    // respond so the thread gets content.
+    let draft_connection = StubAgentConnection::new();
+    draft_connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
+        acp::ContentChunk::new("World".into()),
+    )]);
+    open_thread_with_connection(&panel, draft_connection, cx);
+    send_message(&panel, cx);
+    let new_session_id = active_session_id(&panel, cx);
+    save_test_thread_metadata(&new_session_id, &project, cx).await;
+    cx.run_until_parked();
+
+    // The draft should be gone and the new thread should be active.
+    let entries = visible_entries_as_strings(&sidebar, cx);
+    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
+    assert_eq!(
+        draft_count, 0,
+        "draft should be removed after sending a message"
+    );
+
+    sidebar.read_with(cx, |sidebar, _| {
+        assert_active_thread(
+            sidebar,
+            &new_session_id,
+            "active_entry should transition to the new thread after sending",
+        );
+    });
+}
+
 #[gpui::test]
 async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) {
     // When the active workspace is an absorbed git worktree, cmd-n
@@ -3579,7 +3634,7 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp
         vec![
             //
             "v [project]",
-            "  Hello {wt-feature-a} * (active)",
+            "  Hello {wt-feature-a} *",
         ]
     );
 
@@ -5959,9 +6014,8 @@ async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
 #[gpui::test]
 async fn test_switch_to_workspace_with_archived_thread_shows_draft(cx: &mut TestAppContext) {
     // When a thread is archived while the user is in a different workspace,
-    // the archiving code clears the thread from its panel (via
-    // `clear_active_thread`). Switching back to that workspace should show
-    // a draft, not the archived thread.
+    // the archiving code replaces the thread with a tracked draft in its
+    // panel. Switching back to that workspace should show the draft.
     agent_ui::test_support::init_test(cx);
     cx.update(|cx| {
         ThreadStore::init_global(cx);
@@ -7215,6 +7269,366 @@ async fn test_linked_worktree_workspace_reachable_after_adding_unrelated_project
     );
 }
 
+#[gpui::test]
+async fn test_startup_failed_restoration_shows_draft(cx: &mut TestAppContext) {
+    // Rule 4: When the app starts and the AgentPanel fails to restore its
+    // last thread (no metadata), a draft should appear in the sidebar.
+    let project = init_test_project_with_agent_panel("/my-project", cx).await;
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
+
+    // In tests, AgentPanel::test_new doesn't call `load`, so no
+    // fallback draft is created. The empty group shows a placeholder.
+    // Simulate the startup fallback by creating a draft explicitly.
+    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+    sidebar.update_in(cx, |sidebar, window, cx| {
+        sidebar.create_new_thread(&workspace, window, cx);
+    });
+    cx.run_until_parked();
+
+    assert_eq!(
+        visible_entries_as_strings(&sidebar, cx),
+        vec!["v [my-project]", "  [~ Draft] *"]
+    );
+
+    sidebar.read_with(cx, |sidebar, _| {
+        assert_active_draft(sidebar, &workspace, "should show active draft");
+    });
+}
+
+#[gpui::test]
+async fn test_startup_successful_restoration_no_spurious_draft(cx: &mut TestAppContext) {
+    // Rule 5: When the app starts and the AgentPanel successfully loads
+    // a thread, no spurious draft should appear.
+    let project = init_test_project_with_agent_panel("/my-project", cx).await;
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
+
+    // Create and send a message to make a real thread.
+    let connection = StubAgentConnection::new();
+    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
+        acp::ContentChunk::new("Done".into()),
+    )]);
+    open_thread_with_connection(&panel, connection, cx);
+    send_message(&panel, cx);
+    let session_id = active_session_id(&panel, cx);
+    save_test_thread_metadata(&session_id, &project, cx).await;
+    cx.run_until_parked();
+
+    // Should show the thread, NOT a spurious draft.
+    let entries = visible_entries_as_strings(&sidebar, cx);
+    assert_eq!(entries, vec!["v [my-project]", "  Hello *"]);
+
+    // active_entry should be Thread, not Draft.
+    sidebar.read_with(cx, |sidebar, _| {
+        assert_active_thread(sidebar, &session_id, "should be on the thread, not a draft");
+    });
+}
+
+#[gpui::test]
+async fn test_delete_last_draft_in_empty_group_shows_placeholder(cx: &mut TestAppContext) {
+    // Rule 8: Deleting the last draft in a threadless group should
+    // leave a placeholder draft entry (not an empty group).
+    let project = init_test_project_with_agent_panel("/my-project", cx).await;
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
+
+    // Create two drafts explicitly (test_new doesn't call load).
+    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+    sidebar.update_in(cx, |sidebar, window, cx| {
+        sidebar.create_new_thread(&workspace, window, cx);
+    });
+    cx.run_until_parked();
+    sidebar.update_in(cx, |sidebar, window, cx| {
+        sidebar.create_new_thread(&workspace, window, cx);
+    });
+    cx.run_until_parked();
+
+    assert_eq!(
+        visible_entries_as_strings(&sidebar, cx),
+        vec!["v [my-project]", "  [~ Draft] *", "  [~ Draft]"]
+    );
+
+    // Delete the active (first) draft. The second should become active.
+    let active_draft_id = sidebar.read_with(cx, |_sidebar, cx| {
+        workspace
+            .read(cx)
+            .panel::<AgentPanel>(cx)
+            .unwrap()
+            .read(cx)
+            .active_draft_id()
+            .unwrap()
+    });
+    sidebar.update_in(cx, |sidebar, window, cx| {
+        sidebar.remove_draft(active_draft_id, &workspace, window, cx);
+    });
+    cx.run_until_parked();
+
+    // Should still have 1 draft (the remaining one), now active.
+    let entries = visible_entries_as_strings(&sidebar, cx);
+    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
+    assert_eq!(draft_count, 1, "one draft should remain after deleting one");
+
+    // Delete the last remaining draft.
+    let last_draft_id = sidebar.read_with(cx, |_sidebar, cx| {
+        workspace
+            .read(cx)
+            .panel::<AgentPanel>(cx)
+            .unwrap()
+            .read(cx)
+            .active_draft_id()
+            .unwrap()
+    });
+    sidebar.update_in(cx, |sidebar, window, cx| {
+        sidebar.remove_draft(last_draft_id, &workspace, window, cx);
+    });
+    cx.run_until_parked();
+
+    // The group has no threads and no tracked drafts, so a
+    // placeholder draft should appear.
+    let entries = visible_entries_as_strings(&sidebar, cx);
+    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
+    assert_eq!(
+        draft_count, 1,
+        "placeholder draft should appear after deleting all tracked drafts"
+    );
+}
+
+#[gpui::test]
+async fn test_project_header_click_restores_last_viewed(cx: &mut TestAppContext) {
+    // Rule 9: Clicking a project header should restore whatever the
+    // user was last looking at in that group, not create new drafts
+    // or jump to the first entry.
+    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
+    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
+
+    // Create two threads in project-a.
+    let conn1 = StubAgentConnection::new();
+    conn1.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
+        acp::ContentChunk::new("Done".into()),
+    )]);
+    open_thread_with_connection(&panel_a, conn1, cx);
+    send_message(&panel_a, cx);
+    let thread_a1 = active_session_id(&panel_a, cx);
+    save_test_thread_metadata(&thread_a1, &project_a, cx).await;
+
+    let conn2 = StubAgentConnection::new();
+    conn2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
+        acp::ContentChunk::new("Done".into()),
+    )]);
+    open_thread_with_connection(&panel_a, conn2, cx);
+    send_message(&panel_a, cx);
+    let thread_a2 = active_session_id(&panel_a, cx);
+    save_test_thread_metadata(&thread_a2, &project_a, cx).await;
+    cx.run_until_parked();
+
+    // The user is now looking at thread_a2.
+    sidebar.read_with(cx, |sidebar, _| {
+        assert_active_thread(sidebar, &thread_a2, "should be on thread_a2");
+    });
+
+    // Add project-b and switch to it.
+    let fs = cx.update(|_window, cx| <dyn fs::Fs>::global(cx));
+    fs.as_fake()
+        .insert_tree("/project-b", serde_json::json!({ "src": {} }))
+        .await;
+    let project_b =
+        project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
+    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
+        mw.test_add_workspace(project_b.clone(), window, cx)
+    });
+    let _panel_b = add_agent_panel(&workspace_b, cx);
+    cx.run_until_parked();
+
+    // Now switch BACK to project-a by activating its workspace.
+    let workspace_a = multi_workspace.read_with(cx, |mw, cx| {
+        mw.workspaces()
+            .find(|ws| {
+                ws.read(cx)
+                    .project()
+                    .read(cx)
+                    .visible_worktrees(cx)
+                    .any(|wt| {
+                        wt.read(cx)
+                            .abs_path()
+                            .to_string_lossy()
+                            .contains("project-a")
+                    })
+            })
+            .unwrap()
+            .clone()
+    });
+    multi_workspace.update_in(cx, |mw, window, cx| {
+        mw.activate(workspace_a.clone(), window, cx);
+    });
+    cx.run_until_parked();
+
+    // The panel should still show thread_a2 (the last thing the user
+    // was viewing in project-a), not a draft or thread_a1.
+    sidebar.read_with(cx, |sidebar, _| {
+        assert_active_thread(
+            sidebar,
+            &thread_a2,
+            "switching back to project-a should restore thread_a2",
+        );
+    });
+
+    // No spurious draft entries should have been created in
+    // project-a's group (project-b may have a placeholder).
+    let entries = visible_entries_as_strings(&sidebar, cx);
+    // Find project-a's section and check it has no drafts.
+    let project_a_start = entries
+        .iter()
+        .position(|e| e.contains("project-a"))
+        .unwrap();
+    let project_a_end = entries[project_a_start + 1..]
+        .iter()
+        .position(|e| e.starts_with("v "))
+        .map(|i| i + project_a_start + 1)
+        .unwrap_or(entries.len());
+    let project_a_drafts = entries[project_a_start..project_a_end]
+        .iter()
+        .filter(|e| e.contains("Draft"))
+        .count();
+    assert_eq!(
+        project_a_drafts, 0,
+        "switching back to project-a should not create drafts in its group"
+    );
+}
+
+#[gpui::test]
+async fn test_plus_button_always_creates_new_draft(cx: &mut TestAppContext) {
+    // Rule 3: Clicking the + button on a group should always create
+    // a new draft, even starting from a placeholder (no tracked drafts).
+    let project = init_test_project_with_agent_panel("/my-project", cx).await;
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
+
+    // Start: panel has no tracked drafts, sidebar shows a placeholder.
+    let entries = visible_entries_as_strings(&sidebar, cx);
+    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
+    assert_eq!(draft_count, 1, "should start with 1 placeholder");
+
+    // Simulate what the + button handler does: create exactly one
+    // new draft per click.
+    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+    let simulate_plus_button =
+        |sidebar: &mut Sidebar, window: &mut Window, cx: &mut Context<Sidebar>| {
+            sidebar.create_new_thread(&workspace, window, cx);
+        };
+
+    // First + click: placeholder -> 1 tracked draft.
+    sidebar.update_in(cx, |sidebar, window, cx| {
+        simulate_plus_button(sidebar, window, cx);
+    });
+    cx.run_until_parked();
+
+    let entries = visible_entries_as_strings(&sidebar, cx);
+    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
+    assert_eq!(
+        draft_count, 1,
+        "first + click on placeholder should produce 1 tracked draft"
+    );
+
+    // Second + click: 1 -> 2 drafts.
+    sidebar.update_in(cx, |sidebar, window, cx| {
+        simulate_plus_button(sidebar, window, cx);
+    });
+    cx.run_until_parked();
+
+    let entries = visible_entries_as_strings(&sidebar, cx);
+    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
+    assert_eq!(draft_count, 2, "second + click should add 1 more draft");
+
+    // Third + click: 2 -> 3 drafts.
+    sidebar.update_in(cx, |sidebar, window, cx| {
+        simulate_plus_button(sidebar, window, cx);
+    });
+    cx.run_until_parked();
+
+    let entries = visible_entries_as_strings(&sidebar, cx);
+    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
+    assert_eq!(draft_count, 3, "third + click should add 1 more draft");
+
+    // The most recently created draft should be active (first in list).
+    assert_eq!(entries[1], "  [~ Draft] *");
+}
+
+#[gpui::test]
+async fn test_activating_workspace_with_draft_does_not_create_extras(cx: &mut TestAppContext) {
+    // When a workspace has a draft (from the panel's load fallback)
+    // and the user activates it (e.g. by clicking the placeholder or
+    // the project header), no extra drafts should be created.
+    init_test(cx);
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree("/project-a", serde_json::json!({ ".git": {}, "src": {} }))
+        .await;
+    fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
+        .await;
+    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+
+    let project_a =
+        project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-a".as_ref()], cx).await;
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
+    let sidebar = setup_sidebar(&multi_workspace, cx);
+    let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+    let _panel_a = add_agent_panel(&workspace_a, cx);
+    cx.run_until_parked();
+
+    // Add project-b with its own workspace and agent panel.
+    let project_b =
+        project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
+    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
+        mw.test_add_workspace(project_b.clone(), window, cx)
+    });
+    let _panel_b = add_agent_panel(&workspace_b, cx);
+    cx.run_until_parked();
+
+    // Count project-b's drafts.
+    let count_b_drafts = |cx: &mut gpui::VisualTestContext| {
+        let entries = visible_entries_as_strings(&sidebar, cx);
+        entries
+            .iter()
+            .skip_while(|e| !e.contains("project-b"))
+            .take_while(|e| !e.starts_with("v ") || e.contains("project-b"))
+            .filter(|e| e.contains("Draft"))
+            .count()
+    };
+    let drafts_before = count_b_drafts(cx);
+
+    // Switch away from project-b, then back.
+    multi_workspace.update_in(cx, |mw, window, cx| {
+        mw.activate(workspace_a.clone(), window, cx);
+    });
+    cx.run_until_parked();
+    multi_workspace.update_in(cx, |mw, window, cx| {
+        mw.activate(workspace_b.clone(), window, cx);
+    });
+    cx.run_until_parked();
+
+    let drafts_after = count_b_drafts(cx);
+    assert_eq!(
+        drafts_before, drafts_after,
+        "activating workspace should not create extra drafts"
+    );
+
+    // The draft should be highlighted as active after switching back.
+    sidebar.read_with(cx, |sidebar, _| {
+        assert_active_draft(
+            sidebar,
+            &workspace_b,
+            "draft should be active after switching back to its workspace",
+        );
+    });
+}
+
 mod property_test {
     use super::*;
     use gpui::proptest::prelude::*;
@@ -7886,10 +8300,10 @@ mod property_test {
 
         // 3. The entry must match the agent panel's current state.
         let panel = active_workspace.read(cx).panel::<AgentPanel>(cx).unwrap();
-        if panel.read(cx).active_thread_is_draft(cx) {
+        if panel.read(cx).active_draft_id().is_some() {
             anyhow::ensure!(
                 matches!(entry, ActiveEntry::Draft { .. }),
-                "panel shows a draft but active_entry is {:?}",
+                "panel shows a tracked draft but active_entry is {:?}",
                 entry,
             );
         } else if let Some(session_id) = panel