sidebar: Add another round of UX refinements (#51884)

Danilo Leal created

Change summary

crates/agent_ui/src/thread_metadata_store.rs  |  20 +
crates/agent_ui/src/threads_archive_view.rs   |  68 ++-
crates/recent_projects/src/recent_projects.rs | 329 +++++++++++++++++++-
crates/sidebar/Cargo.toml                     |   1 
crates/sidebar/src/sidebar.rs                 | 300 +++++++++++-------
crates/title_bar/src/title_bar.rs             |  13 
crates/ui/src/components/ai/thread_item.rs    |   3 
crates/workspace/src/multi_workspace.rs       |  14 
8 files changed, 574 insertions(+), 174 deletions(-)

Detailed changes

crates/agent_ui/src/thread_metadata_store.rs 🔗

@@ -184,6 +184,14 @@ impl ThreadMetadataStore {
         })
     }
 
+    pub fn list_sidebar_ids(&self, cx: &App) -> Task<Result<Vec<acp::SessionId>>> {
+        let db = self.db.clone();
+        cx.background_spawn(async move {
+            let s = db.list_sidebar_ids()?;
+            Ok(s)
+        })
+    }
+
     pub fn list(&self, cx: &App) -> Task<Result<Vec<ThreadMetadata>>> {
         let db = self.db.clone();
         cx.background_spawn(async move {
@@ -305,12 +313,22 @@ impl Domain for ThreadMetadataDb {
 db::static_connection!(ThreadMetadataDb, []);
 
 impl ThreadMetadataDb {
-    /// List allsidebar thread session IDs
+    /// List all sidebar thread session IDs.
     pub fn list_ids(&self) -> anyhow::Result<Vec<acp::SessionId>> {
         self.select::<Arc<str>>("SELECT session_id FROM sidebar_threads")?()
             .map(|ids| ids.into_iter().map(|id| acp::SessionId::new(id)).collect())
     }
 
+    /// List session IDs of threads that belong to a real project workspace
+    /// (i.e. have non-empty folder_paths). These are the threads shown in
+    /// the sidebar, as opposed to threads created in empty workspaces.
+    pub fn list_sidebar_ids(&self) -> anyhow::Result<Vec<acp::SessionId>> {
+        self.select::<Arc<str>>(
+            "SELECT session_id FROM sidebar_threads WHERE folder_paths IS NOT NULL AND folder_paths != ''",
+        )?()
+        .map(|ids| ids.into_iter().map(|id| acp::SessionId::new(id)).collect())
+    }
+
     /// List all sidebar thread metadata, ordered by updated_at descending.
     pub fn list(&self) -> anyhow::Result<Vec<ThreadMetadata>> {
         self.select::<ThreadMetadata>(

crates/agent_ui/src/threads_archive_view.rs 🔗

@@ -137,6 +137,7 @@ pub struct ThreadsArchiveView {
     _refresh_history_task: Task<()>,
     _update_items_task: Option<Task<()>>,
     is_loading: bool,
+    has_open_project: bool,
 }
 
 impl ThreadsArchiveView {
@@ -145,6 +146,7 @@ impl ThreadsArchiveView {
         agent_server_store: Entity<AgentServerStore>,
         thread_store: Entity<ThreadStore>,
         fs: Arc<dyn Fs>,
+        has_open_project: bool,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
@@ -182,6 +184,7 @@ impl ThreadsArchiveView {
             _refresh_history_task: Task::ready(()),
             _update_items_task: None,
             is_loading: true,
+            has_open_project,
         };
         this.set_selected_agent(Agent::NativeAgent, window, cx);
         this
@@ -244,7 +247,9 @@ impl ThreadsArchiveView {
         let today = Local::now().naive_local().date();
 
         self._update_items_task.take();
-        let unarchived_ids_task = ThreadMetadataStore::global(cx).read(cx).list_ids(cx);
+        let unarchived_ids_task = ThreadMetadataStore::global(cx)
+            .read(cx)
+            .list_sidebar_ids(cx);
         self._update_items_task = Some(cx.spawn(async move |this, cx| {
             let unarchived_session_ids = unarchived_ids_task.await.unwrap_or_default();
 
@@ -428,6 +433,12 @@ impl ThreadsArchiveView {
         let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else {
             return;
         };
+
+        let thread_has_project = session.work_dirs.as_ref().is_some_and(|p| !p.is_empty());
+        if !thread_has_project && !self.has_open_project {
+            return;
+        }
+
         self.unarchive_thread(session.clone(), window, cx);
     }
 
@@ -444,7 +455,7 @@ impl ThreadsArchiveView {
         match item {
             ArchiveListItem::BucketSeparator(bucket) => div()
                 .w_full()
-                .px_2()
+                .px_2p5()
                 .pt_3()
                 .pb_1()
                 .child(
@@ -476,6 +487,9 @@ impl ThreadsArchiveView {
                     }
                 });
 
+                let thread_has_project = session.work_dirs.as_ref().is_some_and(|p| !p.is_empty());
+                let can_unarchive = thread_has_project || self.has_open_project;
+
                 let supports_delete = self
                     .history
                     .as_ref()
@@ -518,7 +532,7 @@ impl ThreadsArchiveView {
                             .min_w_0()
                             .w_full()
                             .py_1()
-                            .pl_0p5()
+                            .pl_1()
                             .child(title_label)
                             .child(
                                 h_flex()
@@ -550,24 +564,30 @@ impl ThreadsArchiveView {
                             h_flex()
                                 .pr_2p5()
                                 .gap_0p5()
-                                .child(
-                                    Button::new("unarchive-thread", "Unarchive")
-                                        .style(ButtonStyle::OutlinedGhost)
-                                        .label_size(LabelSize::Small)
-                                        .when(is_focused, |this| {
-                                            this.key_binding(
-                                                KeyBinding::for_action_in(
-                                                    &menu::Confirm,
-                                                    &focus_handle,
-                                                    cx,
+                                .when(can_unarchive, |this| {
+                                    this.child(
+                                        Button::new("unarchive-thread", "Unarchive")
+                                            .style(ButtonStyle::OutlinedGhost)
+                                            .label_size(LabelSize::Small)
+                                            .when(is_focused, |this| {
+                                                this.key_binding(
+                                                    KeyBinding::for_action_in(
+                                                        &menu::Confirm,
+                                                        &focus_handle,
+                                                        cx,
+                                                    )
+                                                    .map(|kb| kb.size(rems_from_px(12.))),
                                                 )
-                                                .map(|kb| kb.size(rems_from_px(12.))),
-                                            )
-                                        })
-                                        .on_click(cx.listener(move |this, _, window, cx| {
-                                            this.unarchive_thread(session_info.clone(), window, cx);
-                                        })),
-                                )
+                                            })
+                                            .on_click(cx.listener(move |this, _, window, cx| {
+                                                this.unarchive_thread(
+                                                    session_info.clone(),
+                                                    window,
+                                                    cx,
+                                                );
+                                            })),
+                                    )
+                                })
                                 .when(supports_delete, |this| {
                                     this.child(
                                         IconButton::new("delete-thread", IconName::Trash)
@@ -767,9 +787,11 @@ impl ThreadsArchiveView {
                     .border_b_1()
                     .border_color(cx.theme().colors().border)
                     .child(
-                        Icon::new(IconName::MagnifyingGlass)
-                            .size(IconSize::Small)
-                            .color(Color::Muted),
+                        h_flex().size_4().flex_none().justify_center().child(
+                            Icon::new(IconName::MagnifyingGlass)
+                                .size(IconSize::Small)
+                                .color(Color::Muted),
+                        ),
                     )
                     .child(self.filter_editor.clone())
                     .when(has_query, |this| {

crates/recent_projects/src/recent_projects.rs 🔗

@@ -75,6 +75,7 @@ struct OpenFolderEntry {
 enum ProjectPickerEntry {
     Header(SharedString),
     OpenFolder { index: usize, positions: Vec<usize> },
+    OpenProject(StringMatch),
     RecentProject(StringMatch),
 }
 
@@ -339,19 +340,71 @@ pub fn init(cx: &mut App) {
 
     cx.on_action(|open_recent: &OpenRecent, cx| {
         let create_new_window = open_recent.create_new_window;
-        with_active_or_new_workspace(cx, move |workspace, window, cx| {
-            let Some(recent_projects) = workspace.active_modal::<RecentProjects>(cx) else {
-                let focus_handle = workspace.focus_handle(cx);
-                RecentProjects::open(workspace, create_new_window, window, focus_handle, cx);
-                return;
-            };
 
-            recent_projects.update(cx, |recent_projects, cx| {
-                recent_projects
-                    .picker
-                    .update(cx, |picker, cx| picker.cycle_selection(window, cx))
-            });
-        });
+        match cx
+            .active_window()
+            .and_then(|w| w.downcast::<MultiWorkspace>())
+        {
+            Some(multi_workspace) => {
+                cx.defer(move |cx| {
+                    multi_workspace
+                        .update(cx, |multi_workspace, window, cx| {
+                            let sibling_workspace_ids: HashSet<WorkspaceId> = multi_workspace
+                                .workspaces()
+                                .iter()
+                                .filter_map(|ws| ws.read(cx).database_id())
+                                .collect();
+
+                            let workspace = multi_workspace.workspace().clone();
+                            workspace.update(cx, |workspace, cx| {
+                                let Some(recent_projects) =
+                                    workspace.active_modal::<RecentProjects>(cx)
+                                else {
+                                    let focus_handle = workspace.focus_handle(cx);
+                                    RecentProjects::open(
+                                        workspace,
+                                        create_new_window,
+                                        sibling_workspace_ids,
+                                        window,
+                                        focus_handle,
+                                        cx,
+                                    );
+                                    return;
+                                };
+
+                                recent_projects.update(cx, |recent_projects, cx| {
+                                    recent_projects
+                                        .picker
+                                        .update(cx, |picker, cx| picker.cycle_selection(window, cx))
+                                });
+                            });
+                        })
+                        .log_err();
+                });
+            }
+            None => {
+                with_active_or_new_workspace(cx, move |workspace, window, cx| {
+                    let Some(recent_projects) = workspace.active_modal::<RecentProjects>(cx) else {
+                        let focus_handle = workspace.focus_handle(cx);
+                        RecentProjects::open(
+                            workspace,
+                            create_new_window,
+                            HashSet::new(),
+                            window,
+                            focus_handle,
+                            cx,
+                        );
+                        return;
+                    };
+
+                    recent_projects.update(cx, |recent_projects, cx| {
+                        recent_projects
+                            .picker
+                            .update(cx, |picker, cx| picker.cycle_selection(window, cx))
+                    });
+                });
+            }
+        }
     });
     cx.on_action(|open_remote: &OpenRemote, cx| {
         let from_existing_connection = open_remote.from_existing_connection;
@@ -537,6 +590,7 @@ impl RecentProjects {
     pub fn open(
         workspace: &mut Workspace,
         create_new_window: bool,
+        sibling_workspace_ids: HashSet<WorkspaceId>,
         window: &mut Window,
         focus_handle: FocusHandle,
         cx: &mut Context<Workspace>,
@@ -545,13 +599,14 @@ impl RecentProjects {
         let open_folders = get_open_folders(workspace, cx);
         let project_connection_options = workspace.project().read(cx).remote_connection_options(cx);
         let fs = Some(workspace.app_state().fs.clone());
+
         workspace.toggle_modal(window, cx, |window, cx| {
             let delegate = RecentProjectsDelegate::new(
                 weak,
                 create_new_window,
                 focus_handle,
                 open_folders,
-                HashSet::new(),
+                sibling_workspace_ids,
                 project_connection_options,
                 ProjectPickerStyle::Modal,
             );
@@ -562,7 +617,7 @@ impl RecentProjects {
 
     pub fn popover(
         workspace: WeakEntity<Workspace>,
-        excluded_workspace_ids: HashSet<WorkspaceId>,
+        sibling_workspace_ids: HashSet<WorkspaceId>,
         create_new_window: bool,
         focus_handle: FocusHandle,
         window: &mut Window,
@@ -586,7 +641,7 @@ impl RecentProjects {
                 create_new_window,
                 focus_handle,
                 open_folders,
-                excluded_workspace_ids,
+                sibling_workspace_ids,
                 project_connection_options,
                 ProjectPickerStyle::Popover,
             );
@@ -634,7 +689,7 @@ impl Render for RecentProjects {
 pub struct RecentProjectsDelegate {
     workspace: WeakEntity<Workspace>,
     open_folders: Vec<OpenFolderEntry>,
-    excluded_workspace_ids: HashSet<WorkspaceId>,
+    sibling_workspace_ids: HashSet<WorkspaceId>,
     workspaces: Vec<(
         WorkspaceId,
         SerializedWorkspaceLocation,
@@ -660,7 +715,7 @@ impl RecentProjectsDelegate {
         create_new_window: bool,
         focus_handle: FocusHandle,
         open_folders: Vec<OpenFolderEntry>,
-        excluded_workspace_ids: HashSet<WorkspaceId>,
+        sibling_workspace_ids: HashSet<WorkspaceId>,
         project_connection_options: Option<RemoteConnectionOptions>,
         style: ProjectPickerStyle,
     ) -> Self {
@@ -668,7 +723,7 @@ impl RecentProjectsDelegate {
         Self {
             workspace,
             open_folders,
-            excluded_workspace_ids,
+            sibling_workspace_ids,
             workspaces: Vec::new(),
             filtered_entries: Vec::new(),
             selected_index: 0,
@@ -745,7 +800,11 @@ impl PickerDelegate for RecentProjectsDelegate {
     fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
         matches!(
             self.filtered_entries.get(ix),
-            Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::RecentProject(_))
+            Some(
+                ProjectPickerEntry::OpenFolder { .. }
+                    | ProjectPickerEntry::OpenProject(_)
+                    | ProjectPickerEntry::RecentProject(_)
+            )
         )
     }
 
@@ -780,6 +839,38 @@ impl PickerDelegate for RecentProjectsDelegate {
             ))
         };
 
+        let sibling_candidates: Vec<_> = self
+            .workspaces
+            .iter()
+            .enumerate()
+            .filter(|(_, (id, _, _, _))| self.is_sibling_workspace(*id, cx))
+            .map(|(id, (_, _, paths, _))| {
+                let combined_string = paths
+                    .ordered_paths()
+                    .map(|path| path.compact().to_string_lossy().into_owned())
+                    .collect::<Vec<_>>()
+                    .join("");
+                StringMatchCandidate::new(id, &combined_string)
+            })
+            .collect();
+
+        let mut sibling_matches = smol::block_on(fuzzy::match_strings(
+            &sibling_candidates,
+            query,
+            smart_case,
+            true,
+            100,
+            &Default::default(),
+            cx.background_executor().clone(),
+        ));
+        sibling_matches.sort_unstable_by(|a, b| {
+            b.score
+                .partial_cmp(&a.score)
+                .unwrap_or(std::cmp::Ordering::Equal)
+                .then_with(|| a.candidate_id.cmp(&b.candidate_id))
+        });
+
+        // Build candidates for recent projects (not current, not sibling, not open folder)
         let recent_candidates: Vec<_> = self
             .workspaces
             .iter()
@@ -830,6 +921,33 @@ impl PickerDelegate for RecentProjectsDelegate {
             }
         }
 
+        let has_siblings_to_show = if is_empty_query {
+            !sibling_candidates.is_empty()
+        } else {
+            !sibling_matches.is_empty()
+        };
+
+        if has_siblings_to_show {
+            entries.push(ProjectPickerEntry::Header("Open on This Window".into()));
+
+            if is_empty_query {
+                for (id, (workspace_id, _, _, _)) in self.workspaces.iter().enumerate() {
+                    if self.is_sibling_workspace(*workspace_id, cx) {
+                        entries.push(ProjectPickerEntry::OpenProject(StringMatch {
+                            candidate_id: id,
+                            score: 0.0,
+                            positions: Vec::new(),
+                            string: String::new(),
+                        }));
+                    }
+                }
+            } else {
+                for m in sibling_matches {
+                    entries.push(ProjectPickerEntry::OpenProject(m));
+                }
+            }
+        }
+
         let has_recent_to_show = if is_empty_query {
             !recent_candidates.is_empty()
         } else {
@@ -884,6 +1002,32 @@ impl PickerDelegate for RecentProjectsDelegate {
                 }
                 cx.emit(DismissEvent);
             }
+            Some(ProjectPickerEntry::OpenProject(selected_match)) => {
+                let Some((workspace_id, _, _, _)) =
+                    self.workspaces.get(selected_match.candidate_id)
+                else {
+                    return;
+                };
+                let workspace_id = *workspace_id;
+
+                if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
+                    cx.defer(move |cx| {
+                        handle
+                            .update(cx, |multi_workspace, _window, cx| {
+                                let workspace = multi_workspace
+                                    .workspaces()
+                                    .iter()
+                                    .find(|ws| ws.read(cx).database_id() == Some(workspace_id))
+                                    .cloned();
+                                if let Some(workspace) = workspace {
+                                    multi_workspace.activate(workspace, cx);
+                                }
+                            })
+                            .log_err();
+                    });
+                }
+                cx.emit(DismissEvent);
+            }
             Some(ProjectPickerEntry::RecentProject(selected_match)) => {
                 let Some(workspace) = self.workspace.upgrade() else {
                     return;
@@ -1102,6 +1246,105 @@ impl PickerDelegate for RecentProjectsDelegate {
                         .into_any_element(),
                 )
             }
+            ProjectPickerEntry::OpenProject(hit) => {
+                let (workspace_id, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
+                let workspace_id = *workspace_id;
+                let ordered_paths: Vec<_> = paths
+                    .ordered_paths()
+                    .map(|p| p.compact().to_string_lossy().to_string())
+                    .collect();
+                let tooltip_path: SharedString = match &location {
+                    SerializedWorkspaceLocation::Remote(options) => {
+                        let host = options.display_name();
+                        if ordered_paths.len() == 1 {
+                            format!("{} ({})", ordered_paths[0], host).into()
+                        } else {
+                            format!("{}\n({})", ordered_paths.join("\n"), host).into()
+                        }
+                    }
+                    _ => ordered_paths.join("\n").into(),
+                };
+
+                let mut path_start_offset = 0;
+                let (match_labels, paths): (Vec<_>, Vec<_>) = paths
+                    .ordered_paths()
+                    .map(|p| p.compact())
+                    .map(|path| {
+                        let highlighted_text =
+                            highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
+                        path_start_offset += highlighted_text.1.text.len();
+                        highlighted_text
+                    })
+                    .unzip();
+
+                let prefix = match &location {
+                    SerializedWorkspaceLocation::Remote(options) => {
+                        Some(SharedString::from(options.display_name()))
+                    }
+                    _ => None,
+                };
+
+                let highlighted_match = HighlightedMatchWithPaths {
+                    prefix,
+                    match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
+                    paths,
+                };
+
+                let icon = icon_for_remote_connection(match location {
+                    SerializedWorkspaceLocation::Local => None,
+                    SerializedWorkspaceLocation::Remote(options) => Some(options),
+                });
+
+                let secondary_actions = h_flex()
+                    .gap_1()
+                    .child(
+                        IconButton::new("remove_open_project", IconName::Close)
+                            .icon_size(IconSize::Small)
+                            .tooltip(Tooltip::text("Remove Project from Window"))
+                            .on_click(cx.listener(move |picker, _, window, cx| {
+                                cx.stop_propagation();
+                                window.prevent_default();
+                                picker
+                                    .delegate
+                                    .remove_sibling_workspace(workspace_id, window, cx);
+                                let query = picker.query(cx);
+                                picker.update_matches(query, window, cx);
+                            })),
+                    )
+                    .into_any_element();
+
+                Some(
+                    ListItem::new(ix)
+                        .toggle_state(selected)
+                        .inset(true)
+                        .spacing(ListItemSpacing::Sparse)
+                        .child(
+                            h_flex()
+                                .id("open_project_info_container")
+                                .gap_3()
+                                .flex_grow()
+                                .when(self.has_any_non_local_projects, |this| {
+                                    this.child(Icon::new(icon).color(Color::Muted))
+                                })
+                                .child({
+                                    let mut highlighted = highlighted_match;
+                                    if !self.render_paths {
+                                        highlighted.paths.clear();
+                                    }
+                                    highlighted.render(window, cx)
+                                })
+                                .tooltip(Tooltip::text(tooltip_path)),
+                        )
+                        .map(|el| {
+                            if self.selected_index == ix {
+                                el.end_slot(secondary_actions)
+                            } else {
+                                el.end_hover_slot(secondary_actions)
+                            }
+                        })
+                        .into_any_element(),
+                )
+            }
             ProjectPickerEntry::RecentProject(hit) => {
                 let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
                 let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
@@ -1248,9 +1491,9 @@ impl PickerDelegate for RecentProjectsDelegate {
     fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
         let focus_handle = self.focus_handle.clone();
         let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
-        let open_folder_section = matches!(
+        let is_already_open_entry = matches!(
             self.filtered_entries.get(self.selected_index),
-            Some(ProjectPickerEntry::OpenFolder { .. })
+            Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::OpenProject(_))
         );
 
         if popover_style {
@@ -1304,7 +1547,7 @@ impl PickerDelegate for RecentProjectsDelegate {
                 .border_t_1()
                 .border_color(cx.theme().colors().border_variant)
                 .map(|this| {
-                    if open_folder_section {
+                    if is_already_open_entry {
                         this.child(
                             Button::new("activate", "Activate")
                                 .key_binding(KeyBinding::for_action_in(
@@ -1533,15 +1776,36 @@ impl RecentProjectsDelegate {
         }
     }
 
+    fn remove_sibling_workspace(
+        &mut self,
+        workspace_id: WorkspaceId,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) {
+        if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
+            cx.defer(move |cx| {
+                handle
+                    .update(cx, |multi_workspace, window, cx| {
+                        let index = multi_workspace
+                            .workspaces()
+                            .iter()
+                            .position(|ws| ws.read(cx).database_id() == Some(workspace_id));
+                        if let Some(index) = index {
+                            multi_workspace.remove_workspace(index, window, cx);
+                        }
+                    })
+                    .log_err();
+            });
+        }
+
+        self.sibling_workspace_ids.remove(&workspace_id);
+    }
+
     fn is_current_workspace(
         &self,
         workspace_id: WorkspaceId,
         cx: &mut Context<Picker<Self>>,
     ) -> bool {
-        if self.excluded_workspace_ids.contains(&workspace_id) {
-            return true;
-        }
-
         if let Some(workspace) = self.workspace.upgrade() {
             let workspace = workspace.read(cx);
             if Some(workspace_id) == workspace.database_id() {
@@ -1552,6 +1816,15 @@ impl RecentProjectsDelegate {
         false
     }
 
+    fn is_sibling_workspace(
+        &self,
+        workspace_id: WorkspaceId,
+        cx: &mut Context<Picker<Self>>,
+    ) -> bool {
+        self.sibling_workspace_ids.contains(&workspace_id)
+            && !self.is_current_workspace(workspace_id, cx)
+    }
+
     fn is_open_folder(&self, paths: &PathList) -> bool {
         if self.open_folders.is_empty() {
             return false;
@@ -1574,7 +1847,9 @@ impl RecentProjectsDelegate {
         paths: &PathList,
         cx: &mut Context<Picker<Self>>,
     ) -> bool {
-        !self.is_current_workspace(workspace_id, cx) && !self.is_open_folder(paths)
+        !self.is_current_workspace(workspace_id, cx)
+            && !self.is_sibling_workspace(workspace_id, cx)
+            && !self.is_open_folder(paths)
     }
 }
 

crates/sidebar/Cargo.toml 🔗

@@ -25,6 +25,7 @@ chrono.workspace = true
 editor.workspace = true
 feature_flags.workspace = true
 fs.workspace = true
+git.workspace = true
 gpui.workspace = true
 menu.workspace = true
 project.workspace = true

crates/sidebar/src/sidebar.rs 🔗

@@ -27,14 +27,13 @@ use std::path::Path;
 use std::sync::Arc;
 use theme::ActiveTheme;
 use ui::{
-    AgentThreadStatus, ButtonStyle, CommonAnimationExt as _, HighlightedLabel, KeyBinding,
-    ListItem, PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar,
-    prelude::*,
+    AgentThreadStatus, CommonAnimationExt, Divider, HighlightedLabel, KeyBinding, ListItem,
+    PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, prelude::*,
 };
 use util::ResultExt as _;
 use util::path_list::PathList;
 use workspace::{
-    FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Sidebar as WorkspaceSidebar,
+    FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Open, Sidebar as WorkspaceSidebar,
     ToggleWorkspaceSidebar, Workspace, WorkspaceId,
 };
 
@@ -231,6 +230,7 @@ pub struct Sidebar {
     /// Note: This is NOT the same as the active item.
     selection: Option<usize>,
     focused_thread: Option<acp::SessionId>,
+    agent_panel_visible: bool,
     /// Set to true when WorkspaceRemoved fires so the subsequent
     /// ActiveWorkspaceChanged event knows not to clear focused_thread.
     /// A workspace removal changes the active workspace as a side-effect, but
@@ -361,6 +361,7 @@ impl Sidebar {
             contents: SidebarContents::default(),
             selection: None,
             focused_thread: None,
+            agent_panel_visible: false,
             pending_workspace_removal: false,
             active_entry_index: None,
             hovered_thread_index: None,
@@ -429,8 +430,11 @@ impl Sidebar {
         )
         .detach();
 
+        self.observe_docks(workspace, cx);
+
         if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
             self.subscribe_to_agent_panel(&agent_panel, window, cx);
+            self.agent_panel_visible = AgentPanel::is_visible(workspace, cx);
             // Seed the initial focused_thread so the correct thread item is
             // highlighted right away, without waiting for the panel to emit
             // an event (which only happens on *changes*, not on first load).
@@ -495,6 +499,27 @@ impl Sidebar {
         .detach();
     }
 
+    fn observe_docks(&mut self, workspace: &Entity<Workspace>, cx: &mut Context<Self>) {
+        let workspace = workspace.clone();
+        let docks: Vec<_> = workspace
+            .read(cx)
+            .all_docks()
+            .into_iter()
+            .cloned()
+            .collect();
+        for dock in docks {
+            let workspace = workspace.clone();
+            cx.observe(&dock, move |this, _dock, cx| {
+                let is_visible = AgentPanel::is_visible(&workspace, cx);
+                if this.agent_panel_visible != is_visible {
+                    this.agent_panel_visible = is_visible;
+                    cx.notify();
+                }
+            })
+            .detach();
+        }
+    }
+
     fn observe_draft_editor(&mut self, cx: &mut Context<Self>) {
         self._draft_observation = self
             .multi_workspace
@@ -718,6 +743,10 @@ impl Sidebar {
             }
 
             let path_list = workspace_path_list(workspace, cx);
+            if path_list.paths().is_empty() {
+                continue;
+            }
+
             let label = workspace_label_from_path_list(&path_list);
 
             let is_collapsed = self.collapsed_groups.contains(&path_list);
@@ -1266,9 +1295,11 @@ impl Sidebar {
                     .py_1()
                     .gap_1p5()
                     .child(
-                        Icon::new(disclosure_icon)
-                            .size(IconSize::Small)
-                            .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.6))),
+                        h_flex().size_4().flex_none().justify_center().child(
+                            Icon::new(disclosure_icon)
+                                .size(IconSize::Small)
+                                .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.6))),
+                        ),
                     )
                     .child(label)
                     .when(is_collapsed && has_running_threads, |this| {
@@ -2222,7 +2253,8 @@ impl Sidebar {
         let thread_workspace = thread.workspace.clone();
 
         let is_hovered = self.hovered_thread_index == Some(ix);
-        let is_selected = self.focused_thread.as_ref() == Some(&session_info.session_id);
+        let is_selected = self.agent_panel_visible
+            && self.focused_thread.as_ref() == Some(&session_info.session_id);
         let is_running = matches!(
             thread.status,
             AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
@@ -2363,7 +2395,7 @@ impl Sidebar {
             .map(|w| w.read(cx).focus_handle(cx))
             .unwrap_or_else(|| cx.focus_handle());
 
-        let excluded_workspace_ids: HashSet<WorkspaceId> = multi_workspace
+        let sibling_workspace_ids: HashSet<WorkspaceId> = multi_workspace
             .as_ref()
             .map(|mw| {
                 mw.read(cx)
@@ -2382,7 +2414,7 @@ impl Sidebar {
                 workspace.as_ref().map(|ws| {
                     RecentProjects::popover(
                         ws.clone(),
-                        excluded_workspace_ids.clone(),
+                        sibling_workspace_ids.clone(),
                         false,
                         focus_handle.clone(),
                         window,
@@ -2521,7 +2553,14 @@ impl Sidebar {
         is_selected: bool,
         cx: &mut Context<Self>,
     ) -> AnyElement {
-        let is_active = self.active_entry_index.is_none()
+        let focused_thread_in_list = self.focused_thread.as_ref().is_some_and(|focused_id| {
+            self.contents.entries.iter().any(|entry| {
+                matches!(entry, ListEntry::Thread(t) if &t.session_info.session_id == focused_id)
+            })
+        });
+
+        let is_active = self.agent_panel_visible
+            && !focused_thread_in_list
             && self
                 .multi_workspace
                 .upgrade()
@@ -2542,14 +2581,61 @@ impl Sidebar {
             .selected(is_active)
             .focused(is_selected)
             .title_label_color(Color::Custom(cx.theme().colors().text.opacity(0.85)))
-            .on_click(cx.listener(move |this, _, window, cx| {
-                this.selection = None;
-                this.create_new_thread(&workspace, window, cx);
-            }))
+            .when(!is_active, |this| {
+                this.on_click(cx.listener(move |this, _, window, cx| {
+                    this.selection = None;
+                    this.create_new_thread(&workspace, window, cx);
+                }))
+            })
             .into_any_element()
     }
 
-    fn render_sidebar_header(&self, window: &Window, cx: &mut Context<Self>) -> impl IntoElement {
+    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
+        v_flex()
+            .id("sidebar-empty-state")
+            .p_4()
+            .size_full()
+            .items_center()
+            .justify_center()
+            .gap_1()
+            .track_focus(&self.focus_handle(cx))
+            .child(
+                Button::new("open_project", "Open Project")
+                    .full_width()
+                    .key_binding(KeyBinding::for_action(&workspace::Open::default(), cx))
+                    .on_click(|_, window, cx| {
+                        window.dispatch_action(
+                            Open {
+                                create_new_window: false,
+                            }
+                            .boxed_clone(),
+                            cx,
+                        );
+                    }),
+            )
+            .child(
+                h_flex()
+                    .w_1_2()
+                    .gap_2()
+                    .child(Divider::horizontal())
+                    .child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted))
+                    .child(Divider::horizontal()),
+            )
+            .child(
+                Button::new("clone_repo", "Clone Repository")
+                    .full_width()
+                    .on_click(|_, window, cx| {
+                        window.dispatch_action(git::Clone.boxed_clone(), cx);
+                    }),
+            )
+    }
+
+    fn render_sidebar_header(
+        &self,
+        empty_state: bool,
+        window: &Window,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
         let has_query = self.has_filter_query(cx);
         let traffic_lights = cfg!(target_os = "macos") && !window.is_fullscreen();
         let header_height = platform_title_bar_height(window);
@@ -2582,42 +2668,46 @@ impl Sidebar {
                             .child(self.render_recent_projects_button(cx)),
                     ),
             )
-            .child(
-                h_flex()
-                    .h(Tab::container_height(cx))
-                    .px_1p5()
-                    .gap_1p5()
-                    .border_b_1()
-                    .border_color(cx.theme().colors().border)
-                    .child(
-                        h_flex().size_4().flex_none().justify_center().child(
-                            Icon::new(IconName::MagnifyingGlass)
-                                .size(IconSize::Small)
-                                .color(Color::Muted),
-                        ),
-                    )
-                    .child(self.render_filter_input(cx))
-                    .child(
-                        h_flex()
-                            .gap_1()
-                            .when(
-                                self.selection.is_some()
-                                    && !self.filter_editor.focus_handle(cx).is_focused(window),
-                                |this| this.child(KeyBinding::for_action(&FocusSidebarFilter, cx)),
-                            )
-                            .when(has_query, |this| {
-                                this.child(
-                                    IconButton::new("clear_filter", IconName::Close)
-                                        .icon_size(IconSize::Small)
-                                        .tooltip(Tooltip::text("Clear Search"))
-                                        .on_click(cx.listener(|this, _, window, cx| {
-                                            this.reset_filter_editor_text(window, cx);
-                                            this.update_entries(false, cx);
-                                        })),
+            .when(!empty_state, |this| {
+                this.child(
+                    h_flex()
+                        .h(Tab::container_height(cx))
+                        .px_1p5()
+                        .gap_1p5()
+                        .border_b_1()
+                        .border_color(cx.theme().colors().border)
+                        .child(
+                            h_flex().size_4().flex_none().justify_center().child(
+                                Icon::new(IconName::MagnifyingGlass)
+                                    .size(IconSize::Small)
+                                    .color(Color::Muted),
+                            ),
+                        )
+                        .child(self.render_filter_input(cx))
+                        .child(
+                            h_flex()
+                                .gap_1()
+                                .when(
+                                    self.selection.is_some()
+                                        && !self.filter_editor.focus_handle(cx).is_focused(window),
+                                    |this| {
+                                        this.child(KeyBinding::for_action(&FocusSidebarFilter, cx))
+                                    },
                                 )
-                            }),
-                    ),
-            )
+                                .when(has_query, |this| {
+                                    this.child(
+                                        IconButton::new("clear_filter", IconName::Close)
+                                            .icon_size(IconSize::Small)
+                                            .tooltip(Tooltip::text("Clear Search"))
+                                            .on_click(cx.listener(|this, _, window, cx| {
+                                                this.reset_filter_editor_text(window, cx);
+                                                this.update_entries(false, cx);
+                                            })),
+                                    )
+                                }),
+                        ),
+                )
+            })
     }
 
     fn render_sidebar_toggle_button(&self, _cx: &mut Context<Self>) -> impl IntoElement {
@@ -2678,12 +2768,15 @@ impl Sidebar {
             .agent_server_store()
             .clone();
 
+        let has_open_project = !workspace_path_list(&active_workspace, cx).is_empty();
+
         let archive_view = cx.new(|cx| {
             ThreadsArchiveView::new(
                 agent_connection_store,
                 agent_server_store,
                 thread_store,
                 fs,
+                has_open_project,
                 window,
                 cx,
             )
@@ -2742,6 +2835,10 @@ impl WorkspaceSidebar for Sidebar {
         self.recent_projects_popover_handle.is_deployed()
     }
 
+    fn is_threads_list_view_active(&self) -> bool {
+        matches!(self.view, SidebarView::ThreadList)
+    }
+
     fn prepare_for_focus(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
         self.selection = None;
         cx.notify();
@@ -2765,6 +2862,8 @@ impl Render for Sidebar {
             .title_bar_background
             .blend(cx.theme().colors().panel_background.opacity(0.8));
 
+        let empty_state = self.contents.entries.is_empty();
+
         v_flex()
             .id("workspace-sidebar")
             .key_context("ThreadsSidebar")
@@ -2792,24 +2891,30 @@ impl Render for Sidebar {
             .border_r_1()
             .border_color(cx.theme().colors().border)
             .map(|this| match self.view {
-                SidebarView::ThreadList => {
-                    this.child(self.render_sidebar_header(window, cx)).child(
-                        v_flex()
-                            .relative()
-                            .flex_1()
-                            .overflow_hidden()
-                            .child(
-                                list(
-                                    self.list_state.clone(),
-                                    cx.processor(Self::render_list_entry),
-                                )
-                                .flex_1()
-                                .size_full(),
+                SidebarView::ThreadList => this
+                    .child(self.render_sidebar_header(empty_state, window, cx))
+                    .map(|this| {
+                        if empty_state {
+                            this.child(self.render_empty_state(cx))
+                        } else {
+                            this.child(
+                                v_flex()
+                                    .relative()
+                                    .flex_1()
+                                    .overflow_hidden()
+                                    .child(
+                                        list(
+                                            self.list_state.clone(),
+                                            cx.processor(Self::render_list_entry),
+                                        )
+                                        .flex_1()
+                                        .size_full(),
+                                    )
+                                    .when_some(sticky_header, |this, header| this.child(header))
+                                    .vertical_scrollbar_for(&self.list_state, window, cx),
                             )
-                            .when_some(sticky_header, |this, header| this.child(header))
-                            .vertical_scrollbar_for(&self.list_state, window, cx),
-                    )
-                }
+                        }
+                    }),
                 SidebarView::Archive => {
                     if let Some(archive_view) = &self.archive_view {
                         this.child(archive_view.clone())
@@ -3175,13 +3280,7 @@ mod tests {
 
         assert_eq!(
             visible_entries_as_strings(&sidebar, cx),
-            vec![
-                "v [project-a]",
-                "  [+ New Thread]",
-                "  Thread A1",
-                "v [Empty Workspace]",
-                "  [+ New Thread]"
-            ]
+            vec!["v [project-a]", "  [+ New Thread]", "  Thread A1",]
         );
 
         // Remove the second workspace
@@ -4030,13 +4129,7 @@ mod tests {
         // Thread A is still running; no notification yet.
         assert_eq!(
             visible_entries_as_strings(&sidebar, cx),
-            vec![
-                "v [project-a]",
-                "  [+ New Thread]",
-                "  Hello * (running)",
-                "v [Empty Workspace]",
-                "  [+ New Thread]",
-            ]
+            vec!["v [project-a]", "  [+ New Thread]", "  Hello * (running)",]
         );
 
         // Complete thread A's turn (transition Running → Completed).
@@ -4046,13 +4139,7 @@ mod tests {
         // The completed background thread shows a notification indicator.
         assert_eq!(
             visible_entries_as_strings(&sidebar, cx),
-            vec![
-                "v [project-a]",
-                "  [+ New Thread]",
-                "  Hello * (!)",
-                "v [Empty Workspace]",
-                "  [+ New Thread]",
-            ]
+            vec!["v [project-a]", "  [+ New Thread]", "  Hello * (!)",]
         );
     }
 
@@ -4271,10 +4358,6 @@ mod tests {
                 "  [+ New Thread]",
                 "  Fix bug in sidebar",
                 "  Add tests for editor",
-                "v [Empty Workspace]",
-                "  [+ New Thread]",
-                "  Refactor sidebar layout",
-                "  Fix typo in README",
             ]
         );
 
@@ -4282,19 +4365,14 @@ mod tests {
         type_in_search(&sidebar, "sidebar", cx);
         assert_eq!(
             visible_entries_as_strings(&sidebar, cx),
-            vec![
-                "v [project-a]",
-                "  Fix bug in sidebar  <== selected",
-                "v [Empty Workspace]",
-                "  Refactor sidebar layout",
-            ]
+            vec!["v [project-a]", "  Fix bug in sidebar  <== selected",]
         );
 
         // "typo" only matches in the second workspace — the first header disappears.
         type_in_search(&sidebar, "typo", cx);
         assert_eq!(
             visible_entries_as_strings(&sidebar, cx),
-            vec!["v [Empty Workspace]", "  Fix typo in README  <== selected",]
+            Vec::<String>::new()
         );
 
         // "project-a" matches the first workspace name — the header appears
@@ -4373,12 +4451,7 @@ mod tests {
         type_in_search(&sidebar, "sidebar", cx);
         assert_eq!(
             visible_entries_as_strings(&sidebar, cx),
-            vec![
-                "v [alpha-project]",
-                "  Fix bug in sidebar  <== selected",
-                "v [Empty Workspace]",
-                "  Refactor sidebar layout",
-            ]
+            vec!["v [alpha-project]", "  Fix bug in sidebar  <== selected",]
         );
 
         // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
@@ -4388,12 +4461,7 @@ mod tests {
         type_in_search(&sidebar, "fix", cx);
         assert_eq!(
             visible_entries_as_strings(&sidebar, cx),
-            vec![
-                "v [alpha-project]",
-                "  Fix bug in sidebar  <== selected",
-                "v [Empty Workspace]",
-                "  Fix typo in README",
-            ]
+            vec!["v [alpha-project]", "  Fix bug in sidebar  <== selected",]
         );
 
         // A query that matches a workspace name AND a thread in that same workspace.
@@ -4605,13 +4673,7 @@ mod tests {
 
         assert_eq!(
             visible_entries_as_strings(&sidebar, cx),
-            vec![
-                "v [my-project]",
-                "  [+ New Thread]",
-                "  Historical Thread",
-                "v [Empty Workspace]",
-                "  [+ New Thread]",
-            ]
+            vec!["v [my-project]", "  [+ New Thread]", "  Historical Thread",]
         );
 
         // Switch to workspace 1 so we can verify the confirm switches back.

crates/title_bar/src/title_bar.rs 🔗

@@ -785,7 +785,14 @@ impl TitleBar {
 
         let is_sidebar_open = self.platform_titlebar.read(cx).is_workspace_sidebar_open();
 
-        if is_sidebar_open {
+        let is_threads_list_view_active = self
+            .multi_workspace
+            .as_ref()
+            .and_then(|mw| mw.upgrade())
+            .map(|mw| mw.read(cx).is_threads_list_view_active(cx))
+            .unwrap_or(false);
+
+        if is_sidebar_open && is_threads_list_view_active {
             return self
                 .render_project_name_with_sidebar_popover(display_name, is_project_selected, cx)
                 .into_any_element();
@@ -796,7 +803,7 @@ impl TitleBar {
             .map(|w| w.read(cx).focus_handle(cx))
             .unwrap_or_else(|| cx.focus_handle());
 
-        let excluded_workspace_ids: HashSet<WorkspaceId> = self
+        let sibling_workspace_ids: HashSet<WorkspaceId> = self
             .multi_workspace
             .as_ref()
             .and_then(|mw| mw.upgrade())
@@ -813,7 +820,7 @@ impl TitleBar {
             .menu(move |window, cx| {
                 Some(recent_projects::RecentProjects::popover(
                     workspace.clone(),
-                    excluded_workspace_ids.clone(),
+                    sibling_workspace_ids.clone(),
                     false,
                     focus_handle.clone(),
                     window,

crates/ui/src/components/ai/thread_item.rs 🔗

@@ -318,7 +318,8 @@ impl RenderOnce for ThreadItem {
             .overflow_hidden()
             .cursor_pointer()
             .w_full()
-            .p_1()
+            .py_1()
+            .px_1p5()
             .when(self.selected, |s| s.bg(color.element_active))
             .border_1()
             .border_color(gpui::transparent_black())

crates/workspace/src/multi_workspace.rs 🔗

@@ -43,6 +43,9 @@ pub trait Sidebar: Focusable + Render + Sized {
     fn has_notifications(&self, cx: &App) -> bool;
     fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App);
     fn is_recent_projects_popover_deployed(&self) -> bool;
+    fn is_threads_list_view_active(&self) -> bool {
+        true
+    }
     /// Makes focus reset bac to the search editor upon toggling the sidebar from outside
     fn prepare_for_focus(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
 }
@@ -58,6 +61,7 @@ pub trait SidebarHandle: 'static + Send + Sync {
     fn entity_id(&self) -> EntityId;
     fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App);
     fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool;
+    fn is_threads_list_view_active(&self, cx: &App) -> bool;
 }
 
 #[derive(Clone)]
@@ -112,6 +116,10 @@ impl<T: Sidebar> SidebarHandle for Entity<T> {
     fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool {
         self.read(cx).is_recent_projects_popover_deployed()
     }
+
+    fn is_threads_list_view_active(&self, cx: &App) -> bool {
+        self.read(cx).is_threads_list_view_active()
+    }
 }
 
 pub struct MultiWorkspace {
@@ -191,6 +199,12 @@ impl MultiWorkspace {
             .map_or(false, |s| s.is_recent_projects_popover_deployed(cx))
     }
 
+    pub fn is_threads_list_view_active(&self, cx: &App) -> bool {
+        self.sidebar
+            .as_ref()
+            .map_or(false, |s| s.is_threads_list_view_active(cx))
+    }
+
     pub fn multi_workspace_enabled(&self, cx: &App) -> bool {
         cx.has_flag::<AgentV2FeatureFlag>() && !DisableAiSettings::get_global(cx).disable_ai
     }