diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index 97211569750d12108b489aa2d63cba136f616177..c3b5e9818b497b5b190abdd714d9ff8d5bb3d2a6 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -184,6 +184,14 @@ impl ThreadMetadataStore { }) } + pub fn list_sidebar_ids(&self, cx: &App) -> Task>> { + 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>> { 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> { self.select::>("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> { + self.select::>( + "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> { self.select::( diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index 560f5631573901bbb52689feb0e09dacefe63b66..5300eea3080071d53d654f87dc90debddcfcad16 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -137,6 +137,7 @@ pub struct ThreadsArchiveView { _refresh_history_task: Task<()>, _update_items_task: Option>, is_loading: bool, + has_open_project: bool, } impl ThreadsArchiveView { @@ -145,6 +146,7 @@ impl ThreadsArchiveView { agent_server_store: Entity, thread_store: Entity, fs: Arc, + has_open_project: bool, window: &mut Window, cx: &mut Context, ) -> 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| { diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 010e5c82e1cbab88062ff12481ae28478a89e6c9..b15e6598d6106517e77d5647e776f33e298933a1 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -75,6 +75,7 @@ struct OpenFolderEntry { enum ProjectPickerEntry { Header(SharedString), OpenFolder { index: usize, positions: Vec }, + 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::(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::()) + { + Some(multi_workspace) => { + cx.defer(move |cx| { + multi_workspace + .update(cx, |multi_workspace, window, cx| { + let sibling_workspace_ids: HashSet = 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::(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::(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, window: &mut Window, focus_handle: FocusHandle, cx: &mut Context, @@ -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, - excluded_workspace_ids: HashSet, + sibling_workspace_ids: HashSet, 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, open_folders: Vec, - excluded_workspace_ids: HashSet, + sibling_workspace_ids: HashSet, workspaces: Vec<( WorkspaceId, SerializedWorkspaceLocation, @@ -660,7 +715,7 @@ impl RecentProjectsDelegate { create_new_window: bool, focus_handle: FocusHandle, open_folders: Vec, - excluded_workspace_ids: HashSet, + sibling_workspace_ids: HashSet, project_connection_options: Option, 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>) -> 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::>() + .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::() { + 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>) -> Option { 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>, + ) { + if let Some(handle) = window.window_handle().downcast::() { + 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>, ) -> 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>, + ) -> 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>, ) -> 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) } } diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index e790d2b74d97eddfe781a9f048c47038a61db893..6b4d93790236f32b0533374626e337f5c05ab75b 100644 --- a/crates/sidebar/Cargo.toml +++ b/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 diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 9bd97406411ce1c0052dd84adc70e8073ad34e28..9897108376f6fc8a5fd39cf17fd1eca2d63d2437 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/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, focused_thread: Option, + 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::(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, cx: &mut Context) { + 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._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 = multi_workspace + let sibling_workspace_ids: HashSet = 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, ) -> 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) -> impl IntoElement { + fn render_empty_state(&self, cx: &mut Context) -> 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, + ) -> 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) -> 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.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::::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. diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 5c3993f4e5a0e55fc49207f000633943ec50b5b5..ccdf34bee36688db7113a5aea646c30587d6baec 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/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 = self + let sibling_workspace_ids: HashSet = 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, diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 3c79b53756d3618743a021bb1174f688976f3653..152168dafb43d8b565cba02ad096d565479741a4 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/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()) diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index fb87ec241291b8b249b2b66a8a47ca464f6cdd0c..65ce1f17d76d14e565a4b8761bae14b9ad5f7aa3 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/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) {} } @@ -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 SidebarHandle for Entity { 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::() && !DisableAiSettings::get_global(cx).disable_ai }