diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 7d157c6ad6fe44ef13a88d8e54b28b40042aeed8..7e7aca1c25a0db1acf97be92c7889049ab05b339 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/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 { diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 4c1e1e6419cd6bd5d68d988ff69c7198b437e6bb..3cc959fb7f2aaa06ad12e2b30d84523bc62c294c 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/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::>() diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 7ecc6a399d66f9cf593f5d0574cfcc060b634cca..ac6522eb31e9e8270fd81714ddd535033daf699b 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/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, + excluded_workspace_ids: HashSet, 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, open_folders: Vec, + excluded_workspace_ids: HashSet, workspaces: Vec<( WorkspaceId, SerializedWorkspaceLocation, @@ -652,6 +657,7 @@ impl RecentProjectsDelegate { create_new_window: bool, focus_handle: FocusHandle, open_folders: Vec, + excluded_workspace_ids: HashSet, project_connection_options: Option, 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>, ) -> 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() { diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index fa8a08a8ef500ae688b876b0b498f3a934634787..da5123af210cdb6bc7e824dc8d956b56a5c53b70 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/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::(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) { + 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) -> 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 = 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); diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 10249c3187a472951604983505aec32a398b92c2..5622604aa5aea2c955be2773cb7b962b13fe3906 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/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 = 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, diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 07f7e6464654716fe456a087fc9cf5482112c175..85432a448f4139fea48178ababd502e8412a7dfd 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/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(); })); diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 2e581fb89302910629ac785a0a1a703d9a0e69d4..29346e47d9b21dade0bddb0cb0882188c32a19c2 100644 --- a/crates/workspace/src/persistence.rs +++ b/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 = 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 = 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" ); }