sidebar: Add some more improvements (#51799)

Danilo Leal created

Follow up to https://github.com/zed-industries/zed/pull/51763

Release Notes:

- N/A

Change summary

crates/agent_ui/src/agent_panel.rs                   |   3 
crates/agent_ui/src/conversation_view/thread_view.rs |   5 
crates/recent_projects/src/recent_projects.rs        |  11 +
crates/sidebar/src/sidebar.rs                        | 108 ++++++++++---
crates/title_bar/src/title_bar.rs                    |  17 ++
crates/workspace/src/multi_workspace.rs              |   4 
crates/workspace/src/persistence.rs                  |  43 ++++
7 files changed, 151 insertions(+), 40 deletions(-)

Detailed changes

crates/agent_ui/src/agent_panel.rs 🔗

@@ -82,7 +82,7 @@ use ui::{
 use util::{ResultExt as _, debug_panic};
 use workspace::{
     CollaboratorId, DraggedSelection, DraggedTab, OpenResult, PathList, SerializedPathList,
-    ToggleZoom, ToolbarItemView, Workspace, WorkspaceId,
+    ToggleWorkspaceSidebar, ToggleZoom, ToolbarItemView, Workspace, WorkspaceId,
     dock::{DockPosition, Panel, PanelEvent},
 };
 use zed_actions::{
@@ -3435,6 +3435,7 @@ impl AgentPanel {
                             .action("Profiles", Box::new(ManageProfiles::default()))
                             .action("Settings", Box::new(OpenSettings))
                             .separator()
+                            .action("Toggle Threads Sidebar", Box::new(ToggleWorkspaceSidebar))
                             .action(full_screen_label, Box::new(ToggleZoom));
 
                         if has_auth_methods {

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

@@ -999,7 +999,10 @@ impl ThreadView {
                 let text: String = contents
                     .iter()
                     .filter_map(|block| match block {
-                        acp::ContentBlock::Text(text_content) => Some(text_content.text.as_str()),
+                        acp::ContentBlock::Text(text_content) => Some(text_content.text.clone()),
+                        acp::ContentBlock::ResourceLink(resource_link) => {
+                            Some(format!("@{}", resource_link.name))
+                        }
                         _ => None,
                     })
                     .collect::<Vec<_>>()

crates/recent_projects/src/recent_projects.rs 🔗

@@ -5,6 +5,7 @@ mod remote_servers;
 mod ssh_config;
 
 use std::{
+    collections::HashSet,
     path::{Path, PathBuf},
     sync::Arc,
 };
@@ -547,6 +548,7 @@ impl RecentProjects {
                 create_new_window,
                 focus_handle,
                 open_folders,
+                HashSet::new(),
                 project_connection_options,
                 ProjectPickerStyle::Modal,
             );
@@ -557,6 +559,7 @@ impl RecentProjects {
 
     pub fn popover(
         workspace: WeakEntity<Workspace>,
+        excluded_workspace_ids: HashSet<WorkspaceId>,
         create_new_window: bool,
         focus_handle: FocusHandle,
         window: &mut Window,
@@ -580,6 +583,7 @@ impl RecentProjects {
                 create_new_window,
                 focus_handle,
                 open_folders,
+                excluded_workspace_ids,
                 project_connection_options,
                 ProjectPickerStyle::Popover,
             );
@@ -627,6 +631,7 @@ impl Render for RecentProjects {
 pub struct RecentProjectsDelegate {
     workspace: WeakEntity<Workspace>,
     open_folders: Vec<OpenFolderEntry>,
+    excluded_workspace_ids: HashSet<WorkspaceId>,
     workspaces: Vec<(
         WorkspaceId,
         SerializedWorkspaceLocation,
@@ -652,6 +657,7 @@ impl RecentProjectsDelegate {
         create_new_window: bool,
         focus_handle: FocusHandle,
         open_folders: Vec<OpenFolderEntry>,
+        excluded_workspace_ids: HashSet<WorkspaceId>,
         project_connection_options: Option<RemoteConnectionOptions>,
         style: ProjectPickerStyle,
     ) -> Self {
@@ -659,6 +665,7 @@ impl RecentProjectsDelegate {
         Self {
             workspace,
             open_folders,
+            excluded_workspace_ids,
             workspaces: Vec::new(),
             filtered_entries: Vec::new(),
             selected_index: 0,
@@ -1546,6 +1553,10 @@ impl RecentProjectsDelegate {
         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() {

crates/sidebar/src/sidebar.rs 🔗

@@ -30,7 +30,7 @@ use util::ResultExt as _;
 use util::path_list::PathList;
 use workspace::{
     FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Sidebar as WorkspaceSidebar,
-    ToggleWorkspaceSidebar, Workspace,
+    ToggleWorkspaceSidebar, Workspace, WorkspaceId,
 };
 
 use zed_actions::OpenRecent;
@@ -268,10 +268,6 @@ impl Sidebar {
             window,
             |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
                 MultiWorkspaceEvent::ActiveWorkspaceChanged => {
-                    // Don't clear focused_thread when the active workspace
-                    // changed because a workspace was removed — the focused
-                    // thread may still be valid in the new active workspace.
-                    // Only clear it for explicit user-initiated switches.
                     if mem::take(&mut this.pending_workspace_removal) {
                         // If the removed workspace had no focused thread, seed
                         // from the new active panel so its current thread gets
@@ -288,7 +284,21 @@ impl Sidebar {
                             }
                         }
                     } else {
-                        this.focused_thread = None;
+                        // Seed focused_thread from the new active panel so
+                        // the sidebar highlights the correct thread.
+                        this.focused_thread = this
+                            .multi_workspace
+                            .upgrade()
+                            .and_then(|mw| {
+                                let ws = mw.read(cx).workspace();
+                                ws.read(cx).panel::<AgentPanel>(cx)
+                            })
+                            .and_then(|panel| {
+                                panel
+                                    .read(cx)
+                                    .active_conversation()
+                                    .and_then(|cv| cv.read(cx).parent_id(cx))
+                            });
                     }
                     this.observe_draft_editor(cx);
                     this.update_entries(false, cx);
@@ -1423,6 +1433,10 @@ impl Sidebar {
     }
 
     fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        if matches!(self.view, SidebarView::Archive) {
+            return;
+        }
+
         if self.selection.is_none() {
             self.filter_editor.focus_handle(cx).focus(window, cx);
         }
@@ -1605,6 +1619,11 @@ impl Sidebar {
             return;
         };
 
+        // Set focused_thread eagerly so the sidebar highlight updates
+        // immediately, rather than waiting for a deferred AgentPanel
+        // event which can race with ActiveWorkspaceChanged clearing it.
+        self.focused_thread = Some(session_info.session_id.clone());
+
         multi_workspace.update(cx, |multi_workspace, cx| {
             multi_workspace.activate(workspace.clone(), cx);
         });
@@ -2070,9 +2089,10 @@ impl Sidebar {
     }
 
     fn render_recent_projects_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
-        let workspace = self
-            .multi_workspace
-            .upgrade()
+        let multi_workspace = self.multi_workspace.upgrade();
+
+        let workspace = multi_workspace
+            .as_ref()
             .map(|mw| mw.read(cx).workspace().downgrade());
 
         let focus_handle = workspace
@@ -2081,13 +2101,31 @@ impl Sidebar {
             .map(|w| w.read(cx).focus_handle(cx))
             .unwrap_or_else(|| cx.focus_handle());
 
+        let excluded_workspace_ids: HashSet<WorkspaceId> = multi_workspace
+            .as_ref()
+            .map(|mw| {
+                mw.read(cx)
+                    .workspaces()
+                    .iter()
+                    .filter_map(|ws| ws.read(cx).database_id())
+                    .collect()
+            })
+            .unwrap_or_default();
+
         let popover_handle = self.recent_projects_popover_handle.clone();
 
         PopoverMenu::new("sidebar-recent-projects-menu")
             .with_handle(popover_handle)
             .menu(move |window, cx| {
                 workspace.as_ref().map(|ws| {
-                    RecentProjects::popover(ws.clone(), false, focus_handle.clone(), window, cx)
+                    RecentProjects::popover(
+                        ws.clone(),
+                        excluded_workspace_ids.clone(),
+                        false,
+                        focus_handle.clone(),
+                        window,
+                        cx,
+                    )
                 })
             })
             .trigger_with_tooltip(
@@ -2300,9 +2338,11 @@ impl Sidebar {
                     .child(
                         h_flex()
                             .gap_1()
-                            .when(self.selection.is_some(), |this| {
-                                this.child(KeyBinding::for_action(&FocusSidebarFilter, cx))
-                            })
+                            .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)
@@ -4594,12 +4634,16 @@ mod tests {
 
         sidebar.read_with(cx, |sidebar, _cx| {
             assert_eq!(
-                sidebar.focused_thread, None,
-                "External workspace switch should clear focused_thread"
+                sidebar.focused_thread.as_ref(),
+                Some(&session_id_a),
+                "Switching workspace should seed focused_thread from the new active panel"
             );
-            assert_eq!(
-                sidebar.active_entry_index, None,
-                "No active entry when no thread is focused"
+            let active_entry = sidebar
+                .active_entry_index
+                .and_then(|ix| sidebar.contents.entries.get(ix));
+            assert!(
+                matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_a),
+                "Active entry should be the seeded thread"
             );
         });
 
@@ -4619,8 +4663,9 @@ mod tests {
         // the selection highlight to jump around.
         sidebar.read_with(cx, |sidebar, _cx| {
             assert_eq!(
-                sidebar.focused_thread, None,
-                "Opening a thread in a non-active panel should not set focused_thread"
+                sidebar.focused_thread.as_ref(),
+                Some(&session_id_a),
+                "Opening a thread in a non-active panel should not change focused_thread"
             );
         });
 
@@ -4631,8 +4676,9 @@ mod tests {
 
         sidebar.read_with(cx, |sidebar, _cx| {
             assert_eq!(
-                sidebar.focused_thread, None,
-                "Defocusing the sidebar should not set focused_thread"
+                sidebar.focused_thread.as_ref(),
+                Some(&session_id_a),
+                "Defocusing the sidebar should not change focused_thread"
             );
         });
 
@@ -4647,19 +4693,23 @@ mod tests {
 
         sidebar.read_with(cx, |sidebar, _cx| {
             assert_eq!(
-                sidebar.focused_thread, None,
-                "Switching workspace should clear focused_thread"
+                sidebar.focused_thread.as_ref(),
+                Some(&session_id_b2),
+                "Switching workspace should seed focused_thread from the new active panel"
             );
-            assert_eq!(
-                sidebar.active_entry_index, None,
-                "No active entry when no thread is focused"
+            let active_entry = sidebar
+                .active_entry_index
+                .and_then(|ix| sidebar.contents.entries.get(ix));
+            assert!(
+                matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b2),
+                "Active entry should be the seeded thread"
             );
         });
 
-        // ── 8. Focusing the agent panel thread restores focused_thread ────
+        // ── 8. Focusing the agent panel thread keeps focused_thread ────
         // Workspace B still has session_id_b2 loaded in the agent panel.
         // Clicking into the thread (simulated by focusing its view) should
-        // set focused_thread via the ThreadFocused event.
+        // keep focused_thread since it was already seeded on workspace switch.
         panel_b.update_in(cx, |panel, window, cx| {
             if let Some(thread_view) = panel.active_conversation_view() {
                 thread_view.read(cx).focus_handle(cx).focus(window, cx);

crates/title_bar/src/title_bar.rs 🔗

@@ -37,6 +37,7 @@ use project::{
 use remote::RemoteConnectionOptions;
 use settings::Settings;
 use settings::WorktreeId;
+use std::collections::HashSet;
 use std::sync::Arc;
 use theme::ActiveTheme;
 use title_bar_settings::TitleBarSettings;
@@ -47,7 +48,7 @@ use ui::{
 use update_version::UpdateVersion;
 use util::ResultExt;
 use workspace::{
-    MultiWorkspace, ToggleWorkspaceSidebar, ToggleWorktreeSecurity, Workspace,
+    MultiWorkspace, ToggleWorkspaceSidebar, ToggleWorktreeSecurity, Workspace, WorkspaceId,
     notifications::NotifyResultExt,
 };
 use zed_actions::OpenRemote;
@@ -761,10 +762,24 @@ impl TitleBar {
             .map(|w| w.read(cx).focus_handle(cx))
             .unwrap_or_else(|| cx.focus_handle());
 
+        let excluded_workspace_ids: HashSet<WorkspaceId> = self
+            .multi_workspace
+            .as_ref()
+            .and_then(|mw| mw.upgrade())
+            .map(|mw| {
+                mw.read(cx)
+                    .workspaces()
+                    .iter()
+                    .filter_map(|ws| ws.read(cx).database_id())
+                    .collect()
+            })
+            .unwrap_or_default();
+
         PopoverMenu::new("recent-projects-menu")
             .menu(move |window, cx| {
                 Some(recent_projects::RecentProjects::popover(
                     workspace.clone(),
+                    excluded_workspace_ids.clone(),
                     false,
                     focus_handle.clone(),
                     window,

crates/workspace/src/multi_workspace.rs 🔗

@@ -654,8 +654,10 @@ impl MultiWorkspace {
             self.pending_removal_tasks.retain(|task| !task.is_ready());
             self.pending_removal_tasks
                 .push(cx.background_spawn(async move {
+                    // Clear the session binding instead of deleting the row so
+                    // the workspace still appears in the recent-projects list.
                     crate::persistence::DB
-                        .delete_workspace_by_id(workspace_id)
+                        .set_session_binding(workspace_id, None, None)
                         .await
                         .log_err();
                 }));

crates/workspace/src/persistence.rs 🔗

@@ -4106,7 +4106,7 @@ mod tests {
     }
 
     #[gpui::test]
-    async fn test_remove_workspace_deletes_db_row(cx: &mut gpui::TestAppContext) {
+    async fn test_remove_workspace_clears_session_binding(cx: &mut gpui::TestAppContext) {
         use crate::multi_workspace::MultiWorkspace;
         use feature_flags::FeatureFlagAppExt;
         use gpui::AppContext as _;
@@ -4170,10 +4170,25 @@ mod tests {
 
         cx.run_until_parked();
 
-        // The row should be deleted, not just have session_id cleared.
+        // The row should still exist so it continues to appear in recent
+        // projects, but the session binding should be cleared so it is not
+        // restored as part of any future session.
         assert!(
-            DB.workspace_for_id(workspace2_db_id).is_none(),
-            "Removed workspace's DB row should be deleted entirely"
+            DB.workspace_for_id(workspace2_db_id).is_some(),
+            "Removed workspace's DB row should be preserved for recent projects"
+        );
+
+        let session_workspaces = DB
+            .last_session_workspace_locations("remove-test-session", None, fs.as_ref())
+            .await
+            .unwrap();
+        let restored_ids: Vec<WorkspaceId> = session_workspaces
+            .iter()
+            .map(|sw| sw.workspace_id)
+            .collect();
+        assert!(
+            !restored_ids.contains(&workspace2_db_id),
+            "Removed workspace should not appear in session restoration"
         );
     }
 
@@ -4361,10 +4376,24 @@ mod tests {
         });
         futures::future::join_all(all_tasks).await;
 
-        // After awaiting, the DB row should be deleted.
+        // The row should still exist (for recent projects), but the session
+        // binding should have been cleared by the pending removal task.
+        assert!(
+            DB.workspace_for_id(workspace2_db_id).is_some(),
+            "Workspace row should be preserved for recent projects"
+        );
+
+        let session_workspaces = DB
+            .last_session_workspace_locations("pending-removal-session", None, fs.as_ref())
+            .await
+            .unwrap();
+        let restored_ids: Vec<WorkspaceId> = session_workspaces
+            .iter()
+            .map(|sw| sw.workspace_id)
+            .collect();
         assert!(
-            DB.workspace_for_id(workspace2_db_id).is_none(),
-            "Pending removal task should have deleted the workspace row when awaited"
+            !restored_ids.contains(&workspace2_db_id),
+            "Pending removal task should have cleared the session binding"
         );
     }