sidebar: Improve new thread button behavior/design (#53429)

Danilo Leal and Mikayla Maki created

This PR removes the labeled "new thread" button when the project group
is empty, making it so, effectively, we only show it when you're in an
empty/draft thread in the active workspace. The "Untitled Thread" label
has also been renamed back to "New Thread". Then, we fixed an issue
where clicking on the new thread icon button would default to creating a
new thread in the main worktree as opposed to the one currently active;
this fix makes the button's behavior match with `cmd-n` from the agent
panel. Lastly, we fixed the "new thread" labeled button not opening the
agent panel when that's closed and organized the data structures a bit
by merging `DraftThread` and `NewThread` all into `DraftThread`.

Release Notes:

- Thread Sidebar: Fixed the new thread icon button not creating a thread
in the current active worktree.
- Thread Sidebar: Fixed click on the new thread button not opening the
agent panel when closed.

---------

Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>

Change summary

crates/sidebar/src/sidebar.rs       | 421 ++++++++++++++++--------------
crates/sidebar/src/sidebar_tests.rs | 226 ++++++++++++++-
2 files changed, 418 insertions(+), 229 deletions(-)

Detailed changes

crates/sidebar/src/sidebar.rs 🔗

@@ -16,9 +16,9 @@ use agent_ui::{
 use chrono::{DateTime, Utc};
 use editor::Editor;
 use gpui::{
-    Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, KeyContext, ListState,
-    Pixels, Render, SharedString, Task, WeakEntity, Window, WindowHandle, linear_color_stop,
-    linear_gradient, list, prelude::*, px,
+    Action as _, AnyElement, App, Context, DismissEvent, Entity, FocusHandle, Focusable,
+    KeyContext, ListState, Pixels, Render, SharedString, Task, WeakEntity, Window, WindowHandle,
+    linear_color_stop, linear_gradient, list, prelude::*, px,
 };
 use menu::{
     Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious,
@@ -141,7 +141,12 @@ impl ActiveEntry {
             (ActiveEntry::Thread { session_id, .. }, ListEntry::Thread(thread)) => {
                 thread.metadata.session_id == *session_id
             }
-            (ActiveEntry::Draft(_workspace), ListEntry::DraftThread { .. }) => true,
+            (
+                ActiveEntry::Draft(_),
+                ListEntry::DraftThread {
+                    workspace: None, ..
+                },
+            ) => true,
             _ => false,
         }
     }
@@ -215,6 +220,7 @@ enum ListEntry {
         has_running_threads: bool,
         waiting_thread_count: usize,
         is_active: bool,
+        has_threads: bool,
     },
     Thread(ThreadEntry),
     ViewMore {
@@ -224,16 +230,9 @@ enum ListEntry {
     /// The user's active draft thread. Shows a prefix of the currently-typed
     /// prompt, or "Untitled Thread" if the prompt is empty.
     DraftThread {
-        worktrees: Vec<WorktreeInfo>,
-    },
-    /// A convenience row for starting a new thread. Shown when a project group
-    /// has no threads, or when an open linked worktree workspace has no threads.
-    /// When `workspace` is `Some`, this entry is for a specific linked worktree
-    /// workspace and can be dismissed (removing that workspace).
-    NewThread {
         key: project::ProjectGroupKey,
-        worktrees: Vec<WorktreeInfo>,
         workspace: Option<Entity<Workspace>>,
+        worktrees: Vec<WorktreeInfo>,
     },
 }
 
@@ -256,35 +255,22 @@ impl ListEntry {
                 ThreadEntryWorkspace::Open(ws) => vec![ws.clone()],
                 ThreadEntryWorkspace::Closed(_) => Vec::new(),
             },
-            ListEntry::DraftThread { .. } => {
-                vec![multi_workspace.workspace().clone()]
-            }
-            ListEntry::ProjectHeader { key, .. } => {
-                // The header only activates the main worktree workspace
-                // (the one whose root paths match the group key's path list).
-                multi_workspace
-                    .workspaces()
-                    .find(|ws| PathList::new(&ws.read(cx).root_paths(cx)) == *key.path_list())
-                    .cloned()
-                    .into_iter()
-                    .collect()
-            }
-            ListEntry::NewThread { key, workspace, .. } => {
-                // When the NewThread entry is for a specific linked worktree
-                // workspace, that workspace is reachable. Otherwise fall back
-                // to the main worktree workspace.
+            ListEntry::DraftThread { workspace, .. } => {
                 if let Some(ws) = workspace {
                     vec![ws.clone()]
                 } else {
-                    multi_workspace
-                        .workspaces()
-                        .find(|ws| PathList::new(&ws.read(cx).root_paths(cx)) == *key.path_list())
-                        .cloned()
-                        .into_iter()
-                        .collect()
+                    // workspace: None means this is the active draft,
+                    // which always lives on the current workspace.
+                    vec![multi_workspace.workspace().clone()]
                 }
             }
-            _ => Vec::new(),
+            ListEntry::ProjectHeader { key, .. } => multi_workspace
+                .workspaces()
+                .find(|ws| PathList::new(&ws.read(cx).root_paths(cx)) == *key.path_list())
+                .cloned()
+                .into_iter()
+                .collect(),
+            ListEntry::ViewMore { .. } => Vec::new(),
         }
     }
 }
@@ -1040,6 +1026,17 @@ impl Sidebar {
                 }
             }
 
+            let has_threads = if !threads.is_empty() {
+                true
+            } else {
+                let store = ThreadMetadataStore::global(cx).read(cx);
+                store
+                    .entries_for_main_worktree_path(&path_list)
+                    .next()
+                    .is_some()
+                    || store.entries_for_path(&path_list).next().is_some()
+            };
+
             if !query.is_empty() {
                 let workspace_highlight_positions =
                     fuzzy_match_positions(&query, &label).unwrap_or_default();
@@ -1078,6 +1075,7 @@ impl Sidebar {
                     has_running_threads,
                     waiting_thread_count,
                     is_active,
+                    has_threads,
                 });
 
                 for thread in matched_threads {
@@ -1096,6 +1094,7 @@ impl Sidebar {
                     has_running_threads,
                     waiting_thread_count,
                     is_active,
+                    has_threads,
                 });
 
                 if is_collapsed {
@@ -1108,32 +1107,31 @@ impl Sidebar {
                         let ws_path_list = workspace_path_list(draft_ws, cx);
                         let worktrees = worktree_info_from_thread_paths(&ws_path_list, &group_key);
                         entries.push(ListEntry::DraftThread {
+                            key: group_key.clone(),
+                            workspace: None,
                             worktrees: worktrees.collect(),
                         });
                     }
                 }
 
-                // Emit NewThread entries:
-                // 1. When the group has zero threads (convenient affordance).
-                // 2. For each open linked worktree workspace in this group
-                //    that has no threads (makes the workspace reachable and
-                //    dismissable).
-                let group_has_no_threads = threads.is_empty() && !group_workspaces.is_empty();
-
-                if !is_draft_for_group && group_has_no_threads {
-                    entries.push(ListEntry::NewThread {
-                        key: group_key.clone(),
-                        worktrees: Vec::new(),
-                        workspace: None,
-                    });
-                }
-
-                // Emit a NewThread for each open linked worktree workspace
-                // that has no threads. Skip the workspace if it's showing
-                // the active draft (it already has a DraftThread entry).
-                if !is_draft_for_group {
+                // Emit a DraftThread for each open linked worktree workspace
+                // that has no threads. Skip the specific workspace that is
+                // showing the active draft (it already has a DraftThread entry
+                // from the block above).
+                {
+                    let draft_ws_id = if is_draft_for_group {
+                        self.active_entry.as_ref().and_then(|e| match e {
+                            ActiveEntry::Draft(ws) => Some(ws.entity_id()),
+                            _ => None,
+                        })
+                    } else {
+                        None
+                    };
                     let thread_store = ThreadMetadataStore::global(cx);
                     for ws in &group_workspaces {
+                        if Some(ws.entity_id()) == draft_ws_id {
+                            continue;
+                        }
                         let ws_path_list = workspace_path_list(ws, cx);
                         let has_linked_worktrees =
                             worktree_info_from_thread_paths(&ws_path_list, &group_key)
@@ -1152,10 +1150,11 @@ impl Sidebar {
                         }
                         let worktrees: Vec<WorktreeInfo> =
                             worktree_info_from_thread_paths(&ws_path_list, &group_key).collect();
-                        entries.push(ListEntry::NewThread {
+
+                        entries.push(ListEntry::DraftThread {
                             key: group_key.clone(),
-                            worktrees,
                             workspace: Some(ws.clone()),
+                            worktrees,
                         });
                     }
                 }
@@ -1295,6 +1294,7 @@ impl Sidebar {
                 has_running_threads,
                 waiting_thread_count,
                 is_active: is_active_group,
+                has_threads,
             } => self.render_project_header(
                 ix,
                 false,
@@ -1305,6 +1305,7 @@ impl Sidebar {
                 *waiting_thread_count,
                 *is_active_group,
                 is_selected,
+                *has_threads,
                 cx,
             ),
             ListEntry::Thread(thread) => self.render_thread(ix, thread, is_active, is_selected, cx),
@@ -1312,14 +1313,17 @@ impl Sidebar {
                 key,
                 is_fully_expanded,
             } => self.render_view_more(ix, key.path_list(), *is_fully_expanded, is_selected, cx),
-            ListEntry::DraftThread { worktrees, .. } => {
-                self.render_draft_thread(ix, is_active, worktrees, is_selected, cx)
-            }
-            ListEntry::NewThread {
+            ListEntry::DraftThread {
                 key,
-                worktrees,
                 workspace,
-            } => self.render_new_thread(ix, key, worktrees, workspace.as_ref(), is_selected, cx),
+                worktrees,
+            } => {
+                if workspace.is_some() {
+                    self.render_new_thread(ix, key, worktrees, workspace.as_ref(), is_selected, cx)
+                } else {
+                    self.render_draft_thread(ix, is_active, worktrees, is_selected, cx)
+                }
+            }
         };
 
         if is_group_header_after_first {
@@ -1369,6 +1373,7 @@ impl Sidebar {
         waiting_thread_count: usize,
         is_active: bool,
         is_focused: bool,
+        has_threads: bool,
         cx: &mut Context<Self>,
     ) -> AnyElement {
         let path_list = key.path_list();
@@ -1386,16 +1391,6 @@ impl Sidebar {
             (IconName::ChevronDown, "Collapse Project")
         };
 
-        let has_new_thread_entry = self.contents.entries.get(ix + 1).is_some_and(|entry| {
-            matches!(
-                entry,
-                ListEntry::NewThread { .. } | ListEntry::DraftThread { .. }
-            )
-        });
-        let show_new_thread_button = !has_new_thread_entry && !self.has_filter_query(cx);
-
-        let workspace = self.workspace_for_group(path_list, cx);
-
         let path_list_for_toggle = path_list.clone();
         let path_list_for_collapse = path_list.clone();
         let view_more_expanded = self.expanded_groups.contains_key(path_list);
@@ -1415,6 +1410,8 @@ impl Sidebar {
             .element_active
             .blend(color.element_background.opacity(0.2));
 
+        let is_ellipsis_menu_open = self.project_header_menu_ix == Some(ix);
+
         h_flex()
             .id(id)
             .group(&group_name)
@@ -1433,7 +1430,6 @@ impl Sidebar {
             .justify_between()
             .child(
                 h_flex()
-                    .cursor_pointer()
                     .relative()
                     .min_w_0()
                     .w_full()
@@ -1486,13 +1482,13 @@ impl Sidebar {
             )
             .child(
                 h_flex()
-                    .when(self.project_header_menu_ix != Some(ix), |this| {
-                        this.visible_on_hover(group_name)
+                    .when(!is_ellipsis_menu_open, |this| {
+                        this.visible_on_hover(&group_name)
                     })
                     .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
                         cx.stop_propagation();
                     })
-                    .child(self.render_project_header_menu(ix, id_prefix, key, cx))
+                    .child(self.render_project_header_ellipsis_menu(ix, id_prefix, key, cx))
                     .when(view_more_expanded && !is_collapsed, |this| {
                         this.child(
                             IconButton::new(
@@ -1514,57 +1510,70 @@ impl Sidebar {
                             })),
                         )
                     })
-                    .when_some(
-                        workspace.filter(|_| show_new_thread_button),
-                        |this, workspace| {
-                            let path_list = path_list.clone();
-                            this.child(
-                                IconButton::new(
-                                    SharedString::from(format!(
-                                        "{id_prefix}project-header-new-thread-{ix}",
-                                    )),
-                                    IconName::Plus,
-                                )
-                                .icon_size(IconSize::Small)
-                                .tooltip(Tooltip::text("New Thread"))
-                                .on_click(cx.listener(
-                                    move |this, _, window, cx| {
-                                        this.collapsed_groups.remove(&path_list);
-                                        this.selection = None;
-                                        this.create_new_thread(&workspace, window, cx);
-                                    },
-                                )),
-                            )
-                        },
-                    ),
+                    .child({
+                        let path_list = path_list.clone();
+                        let focus_handle = self.focus_handle.clone();
+                        IconButton::new(
+                            SharedString::from(format!(
+                                "{id_prefix}project-header-new-thread-{ix}",
+                            )),
+                            IconName::Plus,
+                        )
+                        .icon_size(IconSize::Small)
+                        .tooltip(move |_, cx| {
+                            Tooltip::for_action_in("New Thread", &NewThread, &focus_handle, cx)
+                        })
+                        .on_click(cx.listener(
+                            move |this, _, window, cx| {
+                                this.collapsed_groups.remove(&path_list);
+                                this.selection = None;
+                                let workspace = this
+                                    .active_workspace(cx)
+                                    .filter(|ws| {
+                                        let key = ws.read(cx).project_group_key(cx);
+                                        *key.path_list() == path_list
+                                    })
+                                    .or_else(|| this.workspace_for_group(&path_list, cx));
+                                if let Some(workspace) = workspace {
+                                    this.create_new_thread(&workspace, window, cx);
+                                } else {
+                                    this.open_workspace_for_group(&path_list, window, cx);
+                                }
+                            },
+                        ))
+                    }),
             )
             .map(|this| {
-                let path_list = path_list.clone();
-                this.cursor_pointer()
-                    .when(!is_active, |this| this.hover(|s| s.bg(hover_color)))
-                    .tooltip(Tooltip::text("Open Workspace"))
-                    .on_click(cx.listener(move |this, _, window, cx| {
-                        if let Some(workspace) = this.workspace_for_group(&path_list, cx) {
-                            this.active_entry = Some(ActiveEntry::Draft(workspace.clone()));
-                            if let Some(multi_workspace) = this.multi_workspace.upgrade() {
-                                multi_workspace.update(cx, |multi_workspace, cx| {
-                                    multi_workspace.activate(workspace.clone(), window, cx);
-                                });
-                            }
-                            if AgentPanel::is_visible(&workspace, cx) {
-                                workspace.update(cx, |workspace, cx| {
-                                    workspace.focus_panel::<AgentPanel>(window, cx);
-                                });
+                if !has_threads && is_active {
+                    this
+                } else {
+                    let path_list = path_list.clone();
+                    this.cursor_pointer()
+                        .when(!is_active, |this| this.hover(|s| s.bg(hover_color)))
+                        .tooltip(Tooltip::text("Open Workspace"))
+                        .on_click(cx.listener(move |this, _, window, cx| {
+                            if let Some(workspace) = this.workspace_for_group(&path_list, cx) {
+                                this.active_entry = Some(ActiveEntry::Draft(workspace.clone()));
+                                if let Some(multi_workspace) = this.multi_workspace.upgrade() {
+                                    multi_workspace.update(cx, |multi_workspace, cx| {
+                                        multi_workspace.activate(workspace.clone(), window, cx);
+                                    });
+                                }
+                                if AgentPanel::is_visible(&workspace, cx) {
+                                    workspace.update(cx, |workspace, cx| {
+                                        workspace.focus_panel::<AgentPanel>(window, cx);
+                                    });
+                                }
+                            } else {
+                                this.open_workspace_for_group(&path_list, window, cx);
                             }
-                        } else {
-                            this.open_workspace_for_group(&path_list, window, cx);
-                        }
-                    }))
+                        }))
+                }
             })
             .into_any_element()
     }
 
-    fn render_project_header_menu(
+    fn render_project_header_ellipsis_menu(
         &self,
         ix: usize,
         id_prefix: &str,
@@ -1590,72 +1599,75 @@ impl Sidebar {
                 let multi_workspace = multi_workspace.clone();
                 let project_group_key = project_group_key.clone();
 
-                let menu = ContextMenu::build_persistent(window, cx, move |menu, _window, _cx| {
-                    let mut menu = menu
-                        .header("Project Folders")
-                        .end_slot_action(Box::new(menu::EndSlot));
+                let menu =
+                    ContextMenu::build_persistent(window, cx, move |menu, _window, menu_cx| {
+                        let mut menu = menu
+                            .header("Project Folders")
+                            .end_slot_action(Box::new(menu::EndSlot));
 
-                    for path in project_group_key.path_list().paths() {
-                        let Some(name) = path.file_name() else {
-                            continue;
-                        };
-                        let name: SharedString = name.to_string_lossy().into_owned().into();
-                        let path = path.clone();
-                        let project_group_key = project_group_key.clone();
-                        let multi_workspace = multi_workspace.clone();
-                        menu = menu.entry_with_end_slot_on_hover(
-                            name.clone(),
-                            None,
-                            |_, _| {},
-                            IconName::Close,
-                            "Remove Folder".into(),
-                            move |_window, cx| {
-                                multi_workspace
-                                    .update(cx, |multi_workspace, cx| {
-                                        multi_workspace.remove_folder_from_project_group(
-                                            &project_group_key,
-                                            &path,
-                                            cx,
-                                        );
-                                    })
-                                    .ok();
+                        for path in project_group_key.path_list().paths() {
+                            let Some(name) = path.file_name() else {
+                                continue;
+                            };
+                            let name: SharedString = name.to_string_lossy().into_owned().into();
+                            let path = path.clone();
+                            let project_group_key = project_group_key.clone();
+                            let multi_workspace = multi_workspace.clone();
+                            menu = menu.entry_with_end_slot_on_hover(
+                                name.clone(),
+                                None,
+                                |_, _| {},
+                                IconName::Close,
+                                "Remove Folder".into(),
+                                move |_window, cx| {
+                                    multi_workspace
+                                        .update(cx, |multi_workspace, cx| {
+                                            multi_workspace.remove_folder_from_project_group(
+                                                &project_group_key,
+                                                &path,
+                                                cx,
+                                            );
+                                        })
+                                        .ok();
+                                },
+                            );
+                        }
+
+                        let menu = menu.separator().entry(
+                            "Add Folder to Project",
+                            Some(Box::new(AddFolderToProject)),
+                            {
+                                let project_group_key = project_group_key.clone();
+                                let multi_workspace = multi_workspace.clone();
+                                move |window, cx| {
+                                    multi_workspace
+                                        .update(cx, |multi_workspace, cx| {
+                                            multi_workspace.prompt_to_add_folders_to_project_group(
+                                                &project_group_key,
+                                                window,
+                                                cx,
+                                            );
+                                        })
+                                        .ok();
+                                }
                             },
                         );
-                    }
 
-                    let menu = menu.separator().entry(
-                        "Add Folder to Project",
-                        Some(Box::new(AddFolderToProject)),
-                        {
-                            let project_group_key = project_group_key.clone();
-                            let multi_workspace = multi_workspace.clone();
-                            move |window, cx| {
+                        let project_group_key = project_group_key.clone();
+                        let multi_workspace = multi_workspace.clone();
+                        let weak_menu = menu_cx.weak_entity();
+                        menu.separator()
+                            .entry("Remove Project", None, move |window, cx| {
                                 multi_workspace
                                     .update(cx, |multi_workspace, cx| {
-                                        multi_workspace.prompt_to_add_folders_to_project_group(
-                                            &project_group_key,
-                                            window,
-                                            cx,
-                                        );
+                                        multi_workspace
+                                            .remove_project_group(&project_group_key, window, cx)
+                                            .detach_and_log_err(cx);
                                     })
                                     .ok();
-                            }
-                        },
-                    );
-
-                    let project_group_key = project_group_key.clone();
-                    let multi_workspace = multi_workspace.clone();
-                    menu.separator()
-                        .entry("Remove Project", None, move |window, cx| {
-                            multi_workspace
-                                .update(cx, |multi_workspace, cx| {
-                                    multi_workspace
-                                        .remove_project_group(&project_group_key, window, cx)
-                                        .detach_and_log_err(cx);
-                                })
-                                .ok();
-                        })
-                });
+                                weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
+                            })
+                    });
 
                 let this = this.clone();
                 window
@@ -1713,6 +1725,7 @@ impl Sidebar {
             has_running_threads,
             waiting_thread_count,
             is_active,
+            has_threads,
         } = self.contents.entries.get(header_idx)?
         else {
             return None;
@@ -1730,6 +1743,7 @@ impl Sidebar {
             *has_running_threads,
             *waiting_thread_count,
             *is_active,
+            *has_threads,
             is_selected,
             cx,
         );
@@ -1983,18 +1997,18 @@ impl Sidebar {
                     self.expand_thread_group(&path_list, cx);
                 }
             }
-            ListEntry::DraftThread { .. } => {
-                // Already active — nothing to do.
-            }
-            ListEntry::NewThread { key, workspace, .. } => {
-                let path_list = key.path_list().clone();
-                if let Some(workspace) = workspace
-                    .clone()
-                    .or_else(|| self.workspace_for_group(&path_list, cx))
-                {
+            ListEntry::DraftThread { key, workspace, .. } => {
+                if let Some(workspace) = workspace.clone() {
                     self.create_new_thread(&workspace, window, cx);
                 } else {
-                    self.open_workspace_for_group(&path_list, window, cx);
+                    let path_list = key.path_list().clone();
+                    if let Some(workspace) = self.workspace_for_group(&path_list, cx) {
+                        if !AgentPanel::is_visible(&workspace, cx) {
+                            workspace.update(cx, |workspace, cx| {
+                                workspace.focus_panel::<AgentPanel>(window, cx);
+                            });
+                        }
+                    }
                 }
             }
         }
@@ -2370,10 +2384,7 @@ impl Sidebar {
                 }
             }
             Some(
-                ListEntry::Thread(_)
-                | ListEntry::ViewMore { .. }
-                | ListEntry::NewThread { .. }
-                | ListEntry::DraftThread { .. },
+                ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::DraftThread { .. },
             ) => {
                 for i in (0..ix).rev() {
                     if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(i)
@@ -2401,10 +2412,7 @@ impl Sidebar {
         let header_ix = match self.contents.entries.get(ix) {
             Some(ListEntry::ProjectHeader { .. }) => Some(ix),
             Some(
-                ListEntry::Thread(_)
-                | ListEntry::ViewMore { .. }
-                | ListEntry::NewThread { .. }
-                | ListEntry::DraftThread { .. },
+                ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::DraftThread { .. },
             ) => (0..ix).rev().find(|&i| {
                 matches!(
                     self.contents.entries.get(i),
@@ -2870,7 +2878,7 @@ impl Sidebar {
                 let session_id = thread.metadata.session_id.clone();
                 self.archive_thread(&session_id, window, cx);
             }
-            Some(ListEntry::NewThread {
+            Some(ListEntry::DraftThread {
                 workspace: Some(workspace),
                 ..
             }) => {
@@ -3687,9 +3695,9 @@ impl Sidebar {
     ) -> AnyElement {
         let label: SharedString = if is_active {
             self.active_draft_text(cx)
-                .unwrap_or_else(|| "Untitled Thread".into())
+                .unwrap_or_else(|| "New Thread".into())
         } else {
-            "Untitled Thread".into()
+            "New Thread".into()
         };
 
         let id = SharedString::from(format!("draft-thread-btn-{}", ix));
@@ -3709,7 +3717,16 @@ impl Sidebar {
                     .collect(),
             )
             .selected(true)
-            .focused(is_selected);
+            .focused(is_selected)
+            .on_click(cx.listener(|this, _, window, cx| {
+                if let Some(workspace) = this.active_workspace(cx) {
+                    if !AgentPanel::is_visible(&workspace, cx) {
+                        workspace.update(cx, |workspace, cx| {
+                            workspace.focus_panel::<AgentPanel>(window, cx);
+                        });
+                    }
+                }
+            }));
 
         div()
             .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
@@ -3758,7 +3775,7 @@ impl Sidebar {
                 }
             }));
 
-        // Linked worktree NewThread entries can be dismissed, which removes
+        // Linked worktree DraftThread entries can be dismissed, which removes
         // the workspace from the multi-workspace.
         if let Some(workspace) = workspace.cloned() {
             thread_item = thread_item.action_slot(

crates/sidebar/src/sidebar_tests.rs 🔗

@@ -296,13 +296,17 @@ fn visible_entries_as_strings(
                             format!("  + View More{}", selected)
                         }
                     }
-                    ListEntry::DraftThread { worktrees, .. } => {
-                        let worktree = format_linked_worktree_chips(worktrees);
-                        format!("  [~ Draft{}]{}", worktree, selected)
-                    }
-                    ListEntry::NewThread { worktrees, .. } => {
+                    ListEntry::DraftThread {
+                        workspace,
+                        worktrees,
+                        ..
+                    } => {
                         let worktree = format_linked_worktree_chips(worktrees);
-                        format!("  [+ New Thread{}]{}", worktree, selected)
+                        if workspace.is_some() {
+                            format!("  [+ New Thread{}]{}", worktree, selected)
+                        } else {
+                            format!("  [~ Draft{}]{}", worktree, selected)
+                        }
                     }
                 }
             })
@@ -472,7 +476,7 @@ async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
 
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec!["v [my-project]", "  [+ New Thread]"]
+        vec!["v [my-project]"]
     );
 }
 
@@ -723,6 +727,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                 has_running_threads: false,
                 waiting_thread_count: 0,
                 is_active: true,
+                has_threads: true,
             },
             ListEntry::Thread(ThreadEntry {
                 metadata: ThreadMetadata {
@@ -851,6 +856,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                 has_running_threads: false,
                 waiting_thread_count: 0,
                 is_active: false,
+                has_threads: false,
             },
         ];
 
@@ -1170,10 +1176,10 @@ async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
         cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
-    // An empty project has the header and a new thread button.
+    // An empty project has only the header.
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec!["v [empty-project]", "  [+ New Thread]"]
+        vec!["v [empty-project]"]
     );
 
     // Focus sidebar — focus_in does not set a selection
@@ -1184,11 +1190,7 @@ async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
     cx.dispatch_action(SelectNext);
     assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 
-    // SelectNext moves to the new thread button
-    cx.dispatch_action(SelectNext);
-    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
-
-    // At the end, wraps back to first entry
+    // At the end (only one entry), wraps back to first entry
     cx.dispatch_action(SelectNext);
     assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 
@@ -2689,7 +2691,7 @@ async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
     // Thread is not visible yet — no worktree knows about this path.
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec!["v [project]", "  [+ New Thread]"]
+        vec!["v [project]"]
     );
 
     // Now add the worktree to the git state and trigger a rescan.
@@ -3436,7 +3438,7 @@ async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_proje
                 ListEntry::ViewMore { .. } => {
                     panic!("unexpected `View More` entry while opening linked worktree thread");
                 }
-                ListEntry::DraftThread { .. } | ListEntry::NewThread { .. } => {}
+                ListEntry::DraftThread { .. } => {}
             }
         }
 
@@ -4420,7 +4422,6 @@ async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut Test
         visible_entries_as_strings(&sidebar, cx),
         vec![
             "v [other, project]",
-            "  [+ New Thread]",
             "v [project]",
             "  Worktree Thread {wt-feature-a}",
         ]
@@ -5484,7 +5485,7 @@ async fn test_archive_thread_on_linked_worktree_selects_sibling_thread(cx: &mut
 #[gpui::test]
 async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestAppContext) {
     // When a linked worktree is opened as its own workspace and the user
-    // switches away, the workspace must still be reachable from a NewThread
+    // switches away, the workspace must still be reachable from a DraftThread
     // sidebar entry. Pressing RemoveSelectedThread (shift-backspace) on that
     // entry should remove the workspace.
     init_test(cx);
@@ -5579,7 +5580,7 @@ async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestA
         "linked worktree workspace should be reachable, but reachable are: {reachable:?}"
     );
 
-    // Find the NewThread entry for the linked worktree and dismiss it.
+    // Find the DraftThread entry for the linked worktree and dismiss it.
     let new_thread_ix = sidebar.read_with(cx, |sidebar, _| {
         sidebar
             .contents
@@ -5588,13 +5589,13 @@ async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestA
             .position(|entry| {
                 matches!(
                     entry,
-                    ListEntry::NewThread {
+                    ListEntry::DraftThread {
                         workspace: Some(_),
                         ..
                     }
                 )
             })
-            .expect("expected a NewThread entry for the linked worktree")
+            .expect("expected a DraftThread entry for the linked worktree")
     });
 
     assert_eq!(
@@ -5611,7 +5612,7 @@ async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestA
     assert_eq!(
         multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
         1,
-        "linked worktree workspace should be removed after dismissing NewThread entry"
+        "linked worktree workspace should be removed after dismissing DraftThread entry"
     );
 }
 
@@ -5978,6 +5979,175 @@ async fn test_legacy_thread_with_canonical_path_opens_main_repo_workspace(cx: &m
     );
 }
 
+#[gpui::test]
+async fn test_linked_worktree_workspace_reachable_after_adding_unrelated_project(
+    cx: &mut TestAppContext,
+) {
+    // Regression test for a property-test finding:
+    //   AddLinkedWorktree { project_group_index: 0 }
+    //   AddProject { use_worktree: true }
+    //   AddProject { use_worktree: false }
+    // After these three steps, the linked-worktree workspace was not
+    // reachable from any sidebar entry.
+    agent_ui::test_support::init_test(cx);
+    cx.update(|cx| {
+        ThreadStore::init_global(cx);
+        ThreadMetadataStore::init_global(cx);
+        language_model::LanguageModelRegistry::test(cx);
+        prompt_store::init(cx);
+
+        cx.observe_new(
+            |workspace: &mut Workspace,
+             window: Option<&mut Window>,
+             cx: &mut gpui::Context<Workspace>| {
+                if let Some(window) = window {
+                    let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
+                    workspace.add_panel(panel, window, cx);
+                }
+            },
+        )
+        .detach();
+    });
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/my-project",
+        serde_json::json!({
+            ".git": {},
+            "src": {},
+        }),
+    )
+    .await;
+    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+    let project =
+        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx).await;
+    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
+
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let sidebar = setup_sidebar(&multi_workspace, cx);
+
+    // Step 1: Create a linked worktree for the main project.
+    let worktree_name = "wt-0";
+    let worktree_path = "/worktrees/wt-0";
+
+    fs.insert_tree(
+        worktree_path,
+        serde_json::json!({
+            ".git": "gitdir: /my-project/.git/worktrees/wt-0",
+            "src": {},
+        }),
+    )
+    .await;
+    fs.insert_tree(
+        "/my-project/.git/worktrees/wt-0",
+        serde_json::json!({
+            "commondir": "../../",
+            "HEAD": "ref: refs/heads/wt-0",
+        }),
+    )
+    .await;
+    fs.add_linked_worktree_for_repo(
+        Path::new("/my-project/.git"),
+        false,
+        git::repository::Worktree {
+            path: PathBuf::from(worktree_path),
+            ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
+            sha: "aaa".into(),
+            is_main: false,
+        },
+    )
+    .await;
+
+    let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+    let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
+    main_project
+        .update(cx, |p, cx| p.git_scans_complete(cx))
+        .await;
+    cx.run_until_parked();
+
+    // Step 2: Open the linked worktree as its own workspace.
+    let worktree_project =
+        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [worktree_path.as_ref()], cx).await;
+    worktree_project
+        .update(cx, |p, cx| p.git_scans_complete(cx))
+        .await;
+    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
+        mw.test_add_workspace(worktree_project.clone(), window, cx)
+    });
+    cx.run_until_parked();
+
+    // Step 3: Add an unrelated project.
+    fs.insert_tree(
+        "/other-project",
+        serde_json::json!({
+            ".git": {},
+            "src": {},
+        }),
+    )
+    .await;
+    let other_project = project::Project::test(
+        fs.clone() as Arc<dyn fs::Fs>,
+        ["/other-project".as_ref()],
+        cx,
+    )
+    .await;
+    other_project
+        .update(cx, |p, cx| p.git_scans_complete(cx))
+        .await;
+    multi_workspace.update_in(cx, |mw, window, cx| {
+        mw.test_add_workspace(other_project.clone(), window, cx);
+    });
+    cx.run_until_parked();
+
+    // Force a full sidebar rebuild with all groups expanded.
+    sidebar.update_in(cx, |sidebar, _window, cx| {
+        sidebar.collapsed_groups.clear();
+        let path_lists: Vec<PathList> = sidebar
+            .contents
+            .entries
+            .iter()
+            .filter_map(|entry| match entry {
+                ListEntry::ProjectHeader { key, .. } => Some(key.path_list().clone()),
+                _ => None,
+            })
+            .collect();
+        for path_list in path_lists {
+            sidebar.expanded_groups.insert(path_list, 10_000);
+        }
+        sidebar.update_entries(cx);
+    });
+    cx.run_until_parked();
+
+    // The linked-worktree workspace must be reachable from at least one
+    // sidebar entry — otherwise the user has no way to navigate to it.
+    let worktree_ws_id = worktree_workspace.entity_id();
+    let (all_ids, reachable_ids) = sidebar.read_with(cx, |sidebar, cx| {
+        let mw = multi_workspace.read(cx);
+
+        let all: HashSet<gpui::EntityId> = mw.workspaces().map(|ws| ws.entity_id()).collect();
+        let reachable: HashSet<gpui::EntityId> = sidebar
+            .contents
+            .entries
+            .iter()
+            .flat_map(|entry| entry.reachable_workspaces(mw, cx))
+            .map(|ws| ws.entity_id())
+            .collect();
+        (all, reachable)
+    });
+
+    let unreachable = &all_ids - &reachable_ids;
+    eprintln!("{}", visible_entries_as_strings(&sidebar, cx).join("\n"));
+
+    assert!(
+        unreachable.is_empty(),
+        "workspaces not reachable from any sidebar entry: {:?}\n\
+         (linked-worktree workspace id: {:?})",
+        unreachable,
+        worktree_ws_id,
+    );
+}
+
 mod property_test {
     use super::*;
     use gpui::proptest::prelude::*;
@@ -6236,6 +6406,7 @@ mod property_test {
                     mw.test_add_workspace(project.clone(), window, cx)
                 });
             }
+
             Operation::ArchiveThread { index } => {
                 let session_id = state.saved_thread_ids[index].clone();
                 sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
@@ -6600,18 +6771,20 @@ mod property_test {
             anyhow::bail!("sidebar should still have an associated multi-workspace");
         };
 
-        let mw = multi_workspace.read(cx);
+        let multi_workspace = multi_workspace.read(cx);
 
         let reachable_workspaces: HashSet<gpui::EntityId> = sidebar
             .contents
             .entries
             .iter()
-            .flat_map(|entry| entry.reachable_workspaces(mw, cx))
+            .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
             .map(|ws| ws.entity_id())
             .collect();
 
-        let all_workspace_ids: HashSet<gpui::EntityId> =
-            mw.workspaces().map(|ws| ws.entity_id()).collect();
+        let all_workspace_ids: HashSet<gpui::EntityId> = multi_workspace
+            .workspaces()
+            .map(|ws| ws.entity_id())
+            .collect();
 
         let unreachable = &all_workspace_ids - &reachable_workspaces;
 
@@ -6628,7 +6801,6 @@ mod property_test {
         cases: 50,
         ..Default::default()
     })]
-    #[ignore = "temporarily disabled to unblock PRs from landing"]
     async fn test_sidebar_invariants(
         #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..5)]
         raw_operations: Vec<u32>,