Detailed changes
@@ -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>(
@@ -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| {
@@ -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)
}
}
@@ -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
@@ -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.
@@ -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,
@@ -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())
@@ -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
}